Compare commits

..
56 changed files with 3096 additions and 2319 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
Generated
+1
View File
@@ -11228,6 +11228,7 @@ dependencies = [
"md5 0.8.0",
"rusqlite",
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.17",
"tokio",
+1 -1
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
+196 -103
View File
@@ -6,7 +6,7 @@ import {
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
// updates toggle together, while removals only ever cascade beneath a removed folder.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.modelId),
);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
@@ -277,25 +280,16 @@ function LoadedImportDataDialog({
);
};
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
// new folder can't exist, and a removed folder takes its contents with it.
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null || parent.model !== "folder") break;
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
parentId = parent.parentId;
}
}
return disabled;
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
return item.selected;
}).length;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
model: "workspace",
modelId: existing?.id ?? planned?.id ?? "workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Apply"}
: "Done"}
</Button>
</HStack>
</VStack>
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
}
function ImportTreeRow({
item,
row,
onResolveConflict,
}: {
item: ImportPlanItem;
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
<Icon
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0 flex items-center gap-1.5">
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
@@ -577,29 +590,49 @@ function ImportTreeRow({
{ value: "take_source", label: "Take source" },
]}
/>
<IconTooltip content={actionHelp(item)} iconSize="sm" />
</div>
) : (
actionLabel(item) && (
<InlineCode
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
>
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
/>
)
)}
</>
);
}
function ActionChip({
label,
help,
className,
}: {
label: string;
help: string | null;
className?: string;
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Added since the last import";
return "Not in this workspace yet";
case "update":
return "Changed since the last import";
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Deleted since the last import";
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return "Local edits made since the last import. Importing will revert them if checked";
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return "Changed both here and in the file since the last import";
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
}
}
const foldersFirst = (list: ImportPlanItem[]) => [
const byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: item,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
return foldersFirst(roots).map((r) => toNode(r, new Set()));
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
return [node.data, ...node.children.flatMap(collectItems)];
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
* (deletions, reverting local edits) never ride along with a parent toggle.
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
if (item.model === "workspace") return false;
if (root.action === "delete") return item.action === "delete";
if (item.action === "keep_local") {
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update";
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function nodeCheckedStatus(
node: CheckboxTreeNode<ImportPlanItem>,
): boolean | "indeterminate" | "hidden" {
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
@@ -21,6 +21,8 @@ interface Props<T> {
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
@@ -29,7 +31,9 @@ interface Props<T> {
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(false);
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={help}
help={hideLabel ? undefined : help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o) => {
{options.map((o, i) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+7 -14
View File
@@ -1,19 +1,12 @@
---
name: use-yaak
description: >
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
folders, environments and variables, author HTTP requests, configure
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
send them individually or a whole folder/workspace at once, chain one
request's response into the next, and import existing APIs from OpenAPI,
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
API requests for reuse, to set up API requests for manual testing, to add auth
to a saved request, to turn an OpenAPI or Postman collection into runnable
requests, or to run a saved request suite against staging versus production.
Prefer this over one-off `curl` commands whenever the requests should be
saved, reused, shared, or run as a set.
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
---
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
Two consequences worth holding onto. Requests you create are permanent user data
in an app they use, not scratch files, so name them the way the user would and
clean up anything created just to test. And because the app is right there, the
CLI is usually the wrong place to *read* a response in detail; it is the right
CLI is usually the wrong place to _read_ a response in detail; it is the right
place to build, organize, and run requests.
## The CLI describes itself
@@ -68,7 +68,9 @@ async fn import(
);
}
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
Some(workspace_id) => {
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
}
None => ImportDestination::NewWorkspace,
};
let plan = import::plan_import_resources(
@@ -113,6 +115,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
if keep_local > 0 {
parts.push(format!("{keep_local} with local edits"));
}
let ignored = count(ImportPlanAction::Ignored);
if ignored > 0 {
parts.push(format!("{ignored} ignored"));
}
let unchanged = count(ImportPlanAction::Unchanged);
if unchanged > 0 {
parts.push(format!("{unchanged} unchanged"));
+2 -5
View File
@@ -883,11 +883,8 @@ mod tests {
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("vendor")).expect("create vendor");
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
fs::write(
root.join("package.json"),
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
)
.expect("write package.json");
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#)
.expect("write package.json");
copy_build_assets(root).expect("copy assets");
+1 -1
View File
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::make_vars_hashmap;
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
use yaak_models::util::UpdateSource;
#[test]
fn export_writes_yaak_workspace_file() {
@@ -209,7 +210,10 @@ fn re_import_merges_into_linked_workspace() {
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
@@ -257,3 +261,62 @@ fn re_import_merges_into_linked_workspace() {
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C"));
}
#[test]
fn re_import_leaves_deleted_resources_alone() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success();
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspace_id = db
.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id;
let request_b = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Request B")
.expect("request B imported");
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync).expect("delete request B");
workspace_id
};
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Skipped 1 ignored"));
let query_manager = query_manager(data_dir);
let requests =
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
assert_eq!(requests[0].name, "Request A");
}
+2 -5
View File
@@ -145,11 +145,8 @@ async fn cache_control(req: Request, next: Next) -> Response {
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value = if hashed_name && !is_html {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
let value =
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
-1
View File
@@ -19,7 +19,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
entries
}
pub(crate) async fn build_metadata<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
@@ -177,4 +177,3 @@ async fn send_http_request_inner<R: Runtime>(
Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
+3 -18
View File
@@ -29,16 +29,16 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::send::ResponseBody;
use yaak_commands::responses::locate_response_body;
use yaak_commands::resolve::resolve_grpc_request;
use yaak_commands::responses::locate_response_body;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
use yaak_grpc::{Code, ServiceDefinition};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType,
HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
@@ -1033,20 +1033,6 @@ async fn cmd_commit_import<R: Runtime>(
commit_import(&window, plan)
}
/// Decodes base64 and writes the bytes to a file the user picked.
///
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
@@ -1141,7 +1127,6 @@ async fn cmd_send_http_request<R: Runtime>(
Ok(r)
}
async fn cmd_new_child_window<R: Runtime>(
parent_window: WebviewWindow<R>,
url: &str,
@@ -16,8 +16,7 @@ use std::time::{Duration, Instant};
use tauri::path::BaseDirectory;
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent,
is_dev,
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, is_dev,
};
use tokio::sync::Mutex;
use ts_rs::TS;
@@ -28,10 +27,10 @@ use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{Color, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::plugin_meta::get_plugin_meta;
static EXITING: AtomicBool = AtomicBool::new(false);
@@ -355,9 +354,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
),
)
.await
.unwrap_or_else(|_| Err(yaak_plugins::error::Error::PluginErr(
"Timed out starting the plugin runtime".to_string(),
)));
.unwrap_or_else(|_| {
Err(yaak_plugins::error::Error::PluginErr(
"Timed out starting the plugin runtime".to_string(),
))
});
let manager = match result {
Ok(manager) => manager,
+1 -1
View File
@@ -4,5 +4,5 @@
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
pub use yaak_models::render::{render_grpc_request, render_http_request};
+472 -123
View File
@@ -21,9 +21,9 @@ use crate::notifications::YaakNotifier;
use crate::updates::YaakUpdater;
use log::warn;
use serde::Serialize;
use tauri::{Manager, Runtime, State, WebviewWindow};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{Manager, Runtime, State, WebviewWindow};
use tokio::sync::Mutex;
use yaak_commands::{Host, PluginHost};
use yaak_core::WorkspaceContext;
@@ -32,8 +32,8 @@ use yaak_git::{
BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote,
GitStatusSummary, GitWorktreeStatus, PullResult, PushResult,
};
use yaak_grpc::manager::GrpcHandle;
use yaak_grpc::ServiceDefinition;
use yaak_grpc::manager::GrpcHandle;
use yaak_models::blob_manager::BlobManager;
use yaak_models::models::{
GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
@@ -42,26 +42,26 @@ use yaak_models::models::{
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
GetWorkspaceActionsResponse,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
RenderPurpose,
};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_rpc::RpcRouter;
use yaak_rpc_schema::*;
use yaak_sse::sse::ServerSentEvent;
use yaak_sync::sync::SyncOp;
use yaak_templates::TemplateCallback;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::TemplateCallback;
use yaak_ws::WebsocketManager;
/// Per-call context: the window a command was invoked from.
@@ -231,17 +231,15 @@ impl<R: Runtime> PluginHost for ClientCtx<R> {
Ok(self.pm().await?.call_workspace_action(&self.plugin_context(), req).await?)
}
async fn call_folder_action(
&self,
req: CallFolderActionRequest,
) -> yaak_commands::Result<()> {
async fn call_folder_action(&self, req: CallFolderActionRequest) -> yaak_commands::Result<()> {
Ok(self.pm().await?.call_folder_action(&self.plugin_context(), req).await?)
}
async fn http_authentication_summaries(
&self,
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
let results = self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
let results =
self.pm().await?.get_http_authentication_summaries(&self.plugin_context()).await?;
Ok(results.into_iter().map(|(_, a)| a).collect())
}
@@ -393,11 +391,17 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
Ok(crate::cmd_metadata(ctx.window.app_handle().clone()).await?)
}
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
async fn cmd_template_tokens_to_string<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdTemplateTokensToStringReq,
) -> Result<String> {
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
}
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
async fn cmd_render_template<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdRenderTemplateReq,
) -> Result<String> {
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
}
@@ -405,55 +409,114 @@ async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackRe
Ok(crate::cmd_send_feedback(ctx.window.app_handle().clone(), req.feature, req.text).await?)
}
async fn cmd_dismiss_notification<R: Runtime>(ctx: ClientCtx<R>, req: CmdDismissNotificationReq) -> Result<()> {
Ok(crate::cmd_dismiss_notification(ctx.window.clone(), &req.notification_id, ctx.window.app_handle().state::<Mutex<YaakNotifier>>()).await?)
async fn cmd_dismiss_notification<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDismissNotificationReq,
) -> Result<()> {
Ok(crate::cmd_dismiss_notification(
ctx.window.clone(),
&req.notification_id,
ctx.window.app_handle().state::<Mutex<YaakNotifier>>(),
)
.await?)
}
async fn cmd_grpc_reflect<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcReflectReq) -> Result<Vec<ServiceDefinition>> {
Ok(crate::cmd_grpc_reflect(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
async fn cmd_grpc_reflect<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGrpcReflectReq,
) -> Result<Vec<ServiceDefinition>> {
Ok(crate::cmd_grpc_reflect(
&req.request_id,
req.environment_id.as_deref(),
req.proto_files,
ctx.window.clone(),
ctx.window.app_handle().clone(),
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
)
.await?)
}
async fn cmd_grpc_go<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcGoReq) -> Result<String> {
Ok(crate::cmd_grpc_go(&req.request_id, req.environment_id.as_deref(), req.proto_files, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<GrpcHandle>>()).await?)
Ok(crate::cmd_grpc_go(
&req.request_id,
req.environment_id.as_deref(),
req.proto_files,
ctx.window.app_handle().clone(),
ctx.window.clone(),
ctx.window.app_handle().state::<Mutex<GrpcHandle>>(),
)
.await?)
}
async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Result<()> {
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
}
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
async fn cmd_send_ephemeral_request<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSendEphemeralRequestReq,
) -> Result<EphemeralHttpResponse> {
Ok(crate::cmd_send_ephemeral_request(
req.request,
req.environment_id.as_deref(),
req.cookie_jar_id.as_deref(),
ctx.window.clone(),
ctx.window.app_handle().clone(),
)
.await?)
}
async fn cmd_format_json<R: Runtime>(ctx: ClientCtx<R>, req: CmdFormatJsonReq) -> Result<String> {
Ok(yaak_commands::data::cmd_format_json(ctx, req).await?)
}
async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphqlReq) -> Result<String> {
async fn cmd_format_graphql<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdFormatGraphqlReq,
) -> Result<String> {
Ok(crate::cmd_format_graphql(&req.text).await?)
}
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref()).await?)
async fn cmd_http_response_body<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdHttpResponseBodyReq,
) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), &req.response_id, req.filter.as_deref())
.await?)
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
async fn cmd_http_response_body_path<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdHttpResponseBodyPathReq,
) -> Result<Option<String>> {
Ok(yaak_commands::responses::cmd_http_response_body_path(ctx, req).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
async fn cmd_http_request_body<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdHttpRequestBodyReq,
) -> Result<Option<Vec<u8>>> {
Ok(yaak_commands::responses::cmd_http_request_body(ctx, req).await?)
}
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
async fn cmd_get_sse_events<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetSseEventsReq,
) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
async fn cmd_get_http_response_events<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetHttpResponseEventsReq,
) -> Result<Vec<HttpResponseEvent>> {
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
async fn cmd_import_data<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportDataReq,
) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
@@ -461,16 +524,25 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
async fn cmd_commit_import<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCommitImportReq,
) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_list_import_sources<R: Runtime>(ctx: ClientCtx<R>, req: CmdListImportSourcesReq) -> Result<Vec<ImportSource>> {
async fn cmd_list_import_sources<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdListImportSourcesReq,
) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportSourcesForOriginReq) -> Result<Vec<ImportSource>> {
async fn cmd_import_sources_for_origin<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportSourcesForOriginReq,
) -> Result<Vec<ImportSource>> {
use crate::models_ext::QueryManagerExt;
let origin = match (req.file_path, req.url) {
(Some(file_path), _) => crate::import::file_origin(&file_path).origin,
@@ -483,67 +555,115 @@ async fn cmd_import_sources_for_origin<R: Runtime>(ctx: ClientCtx<R>, req: CmdIm
Ok(ctx.window.db().list_import_sources_by_origin(&origin)?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
async fn cmd_http_request_actions<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdHttpRequestActionsReq,
) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
}
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
async fn cmd_websocket_request_actions<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWebsocketRequestActionsReq,
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
}
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
async fn cmd_call_websocket_request_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallWebsocketRequestActionReq,
) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
}
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
async fn cmd_workspace_actions<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWorkspaceActionsReq,
) -> Result<Vec<GetWorkspaceActionsResponse>> {
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
}
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
async fn cmd_call_workspace_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallWorkspaceActionReq,
) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
}
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
async fn cmd_folder_actions<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdFolderActionsReq,
) -> Result<Vec<GetFolderActionsResponse>> {
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
}
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
async fn cmd_call_folder_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallFolderActionReq,
) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
}
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
async fn cmd_grpc_request_actions<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGrpcRequestActionsReq,
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
}
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
async fn cmd_template_function_summaries<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdTemplateFunctionSummariesReq,
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
}
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
async fn cmd_template_function_config<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdTemplateFunctionConfigReq,
) -> Result<GetTemplateFunctionConfigResponse> {
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
}
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
async fn cmd_get_http_authentication_summaries<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetHttpAuthenticationSummariesReq,
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
}
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
async fn cmd_get_http_authentication_config<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetHttpAuthenticationConfigReq,
) -> Result<GetHttpAuthenticationConfigResponse> {
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
}
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
async fn cmd_call_http_request_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallHttpRequestActionReq,
) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
}
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
async fn cmd_call_grpc_request_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallGrpcRequestActionReq,
) -> Result<()> {
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
}
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
async fn cmd_call_http_authentication_action<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCallHttpAuthenticationActionReq,
) -> Result<()> {
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
}
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
async fn cmd_curl_to_request<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdCurlToRequestReq,
) -> Result<HttpRequest> {
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
}
@@ -551,83 +671,159 @@ async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
}
async fn cmd_save_base64_to_binary<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveBase64ToBinaryReq) -> Result<()> {
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data).await?)
async fn cmd_save_base64_to_binary<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSaveBase64ToBinaryReq,
) -> Result<()> {
Ok(crate::cmd_save_base64_to_binary(ctx.window.app_handle().clone(), &req.filepath, &req.data)
.await?)
}
async fn cmd_save_response<R: Runtime>(ctx: ClientCtx<R>, req: CmdSaveResponseReq) -> Result<()> {
Ok(yaak_commands::responses::cmd_save_response(ctx, req).await?)
}
async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRequestReq) -> Result<HttpResponse> {
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
async fn cmd_send_http_request<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSendHttpRequestReq,
) -> Result<HttpResponse> {
Ok(crate::cmd_send_http_request(
ctx.window.app_handle().clone(),
ctx.window.clone(),
req.environment_id.as_deref(),
req.cookie_jar_id.as_deref(),
req.request_id,
)
.await?)
}
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
async fn cmd_reload_plugins<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdReloadPluginsReq,
) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
}
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
async fn cmd_plugin_info<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginInfoReq,
) -> Result<PluginMetadata> {
Ok(yaak_commands::plugins::cmd_plugin_info(ctx, req).await?)
}
async fn cmd_delete_all_grpc_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllGrpcConnectionsReq) -> Result<()> {
async fn cmd_delete_all_grpc_connections<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDeleteAllGrpcConnectionsReq,
) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_all_grpc_connections(ctx, req).await?)
}
async fn cmd_delete_send_history<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteSendHistoryReq) -> Result<()> {
async fn cmd_delete_send_history<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDeleteSendHistoryReq,
) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_send_history(ctx, req).await?)
}
async fn cmd_delete_all_http_responses<R: Runtime>(ctx: ClientCtx<R>, req: CmdDeleteAllHttpResponsesReq) -> Result<()> {
async fn cmd_delete_all_http_responses<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDeleteAllHttpResponsesReq,
) -> Result<()> {
Ok(yaak_commands::models::cmd_delete_all_http_responses(ctx, req).await?)
}
async fn cmd_get_workspace_meta<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetWorkspaceMetaReq) -> Result<WorkspaceMeta> {
async fn cmd_get_workspace_meta<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetWorkspaceMetaReq,
) -> Result<WorkspaceMeta> {
Ok(yaak_commands::models::cmd_get_workspace_meta(ctx, req).await?)
}
async fn cmd_new_child_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewChildWindowReq) -> Result<()> {
Ok(crate::cmd_new_child_window(ctx.window.clone(), &req.url, &req.label, &req.title, req.inner_size).await?)
async fn cmd_new_child_window<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdNewChildWindowReq,
) -> Result<()> {
Ok(crate::cmd_new_child_window(
ctx.window.clone(),
&req.url,
&req.label,
&req.title,
req.inner_size,
)
.await?)
}
async fn cmd_new_main_window<R: Runtime>(ctx: ClientCtx<R>, req: CmdNewMainWindowReq) -> Result<()> {
async fn cmd_new_main_window<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdNewMainWindowReq,
) -> Result<()> {
Ok(crate::cmd_new_main_window(ctx.window.app_handle().clone(), &req.url).await?)
}
async fn cmd_check_for_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdCheckForUpdatesReq) -> Result<bool> {
Ok(crate::cmd_check_for_updates(ctx.window.clone(), ctx.window.app_handle().state::<Mutex<YaakUpdater>>()).await?)
async fn cmd_check_for_updates<R: Runtime>(
ctx: ClientCtx<R>,
_req: CmdCheckForUpdatesReq,
) -> Result<bool> {
Ok(crate::cmd_check_for_updates(
ctx.window.clone(),
ctx.window.app_handle().state::<Mutex<YaakUpdater>>(),
)
.await?)
}
async fn cmd_decrypt_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdDecryptTemplateReq) -> Result<String> {
async fn cmd_decrypt_template<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDecryptTemplateReq,
) -> Result<String> {
Ok(yaak_commands::encryption::cmd_decrypt_template(ctx, req).await?)
}
async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTemplateReq) -> Result<String> {
async fn cmd_secure_template<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSecureTemplateReq,
) -> Result<String> {
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
}
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
async fn cmd_get_themes<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdGetThemesReq,
) -> Result<Vec<GetThemesResponse>> {
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
}
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
async fn cmd_enable_encryption<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdEnableEncryptionReq,
) -> Result<()> {
Ok(yaak_commands::encryption::cmd_enable_encryption(ctx, req).await?)
}
async fn cmd_reveal_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdRevealWorkspaceKeyReq) -> Result<String> {
async fn cmd_reveal_workspace_key<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdRevealWorkspaceKeyReq,
) -> Result<String> {
Ok(yaak_commands::encryption::cmd_reveal_workspace_key(ctx, req).await?)
}
async fn cmd_set_workspace_key<R: Runtime>(ctx: ClientCtx<R>, req: CmdSetWorkspaceKeyReq) -> Result<()> {
async fn cmd_set_workspace_key<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSetWorkspaceKeyReq,
) -> Result<()> {
Ok(yaak_commands::encryption::cmd_set_workspace_key(ctx, req).await?)
}
async fn cmd_disable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdDisableEncryptionReq) -> Result<()> {
async fn cmd_disable_encryption<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDisableEncryptionReq,
) -> Result<()> {
Ok(yaak_commands::encryption::cmd_disable_encryption(ctx, req).await?)
}
async fn cmd_default_headers<R: Runtime>(ctx: ClientCtx<R>, req: CmdDefaultHeadersReq) -> Result<Vec<HttpRequestHeader>> {
async fn cmd_default_headers<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDefaultHeadersReq,
) -> Result<Vec<HttpRequestHeader>> {
Ok(yaak_commands::models::cmd_default_headers(ctx, req).await?)
}
@@ -649,27 +845,45 @@ async fn models_delete<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDeleteReq) -> R
Ok(deleted?)
}
async fn models_duplicate<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq) -> Result<String> {
async fn models_duplicate<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsDuplicateReq,
) -> Result<String> {
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
}
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
async fn models_websocket_events<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsWebsocketEventsReq,
) -> Result<Vec<WebsocketEvent>> {
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
}
async fn models_grpc_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGrpcEventsReq) -> Result<Vec<GrpcEvent>> {
async fn models_grpc_events<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsGrpcEventsReq,
) -> Result<Vec<GrpcEvent>> {
Ok(yaak_commands::models::models_grpc_events(ctx, req).await?)
}
async fn models_get_settings<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetSettingsReq) -> Result<Settings> {
async fn models_get_settings<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsGetSettingsReq,
) -> Result<Settings> {
Ok(yaak_commands::models::models_get_settings(ctx, req).await?)
}
async fn models_get_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsGetGraphqlIntrospectionReq) -> Result<Option<GraphQlIntrospection>> {
async fn models_get_graphql_introspection<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsGetGraphqlIntrospectionReq,
) -> Result<Option<GraphQlIntrospection>> {
Ok(yaak_commands::models::models_get_graphql_introspection(ctx, req).await?)
}
async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req: ModelsUpsertGraphqlIntrospectionReq) -> Result<GraphQlIntrospection> {
async fn models_upsert_graphql_introspection<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsUpsertGraphqlIntrospectionReq,
) -> Result<GraphQlIntrospection> {
Ok(yaak_commands::models::models_upsert_graphql_introspection(ctx, req).await?)
}
@@ -680,7 +894,10 @@ async fn models_upsert_graphql_introspection<R: Runtime>(ctx: ClientCtx<R>, req:
/// freezes the app"). Escape sequences sidestep it. This is a quirk of the
/// webview transport, not of the data, so it lives in the adapter rather than
/// the shared handler.
async fn models_workspace_models<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWorkspaceModelsReq) -> Result<String> {
async fn models_workspace_models<R: Runtime>(
ctx: ClientCtx<R>,
req: ModelsWorkspaceModelsReq,
) -> Result<String> {
let json = yaak_commands::models::models_workspace_models(ctx, req).await?;
Ok(escape_str_for_webview(&json))
}
@@ -706,7 +923,10 @@ fn escape_str_for_webview(input: &str) -> String {
.collect()
}
async fn cmd_git_checkout<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitCheckoutReq) -> Result<String> {
async fn cmd_git_checkout<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitCheckoutReq,
) -> Result<String> {
Ok(crate::git_ext::cmd_git_checkout(&req.dir, &req.branch, req.force).await?)
}
@@ -714,31 +934,52 @@ async fn cmd_git_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchReq) ->
Ok(crate::git_ext::cmd_git_branch(&req.dir, &req.branch, req.base.as_deref()).await?)
}
async fn cmd_git_delete_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteBranchReq) -> Result<BranchDeleteResult> {
async fn cmd_git_delete_branch<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitDeleteBranchReq,
) -> Result<BranchDeleteResult> {
Ok(crate::git_ext::cmd_git_delete_branch(&req.dir, &req.branch, req.force).await?)
}
async fn cmd_git_delete_remote_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitDeleteRemoteBranchReq) -> Result<()> {
async fn cmd_git_delete_remote_branch<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitDeleteRemoteBranchReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_delete_remote_branch(&req.dir, &req.branch).await?)
}
async fn cmd_git_merge_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitMergeBranchReq) -> Result<()> {
async fn cmd_git_merge_branch<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitMergeBranchReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_merge_branch(&req.dir, &req.branch).await?)
}
async fn cmd_git_rename_branch<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRenameBranchReq) -> Result<()> {
async fn cmd_git_rename_branch<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitRenameBranchReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_rename_branch(&req.dir, &req.old_name, &req.new_name).await?)
}
async fn cmd_git_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitStatusReq) -> Result<GitStatusSummary> {
async fn cmd_git_status<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitStatusReq,
) -> Result<GitStatusSummary> {
Ok(crate::git_ext::cmd_git_status(&req.dir).await?)
}
async fn cmd_git_branch_info<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitBranchInfoReq) -> Result<GitBranchInfo> {
async fn cmd_git_branch_info<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitBranchInfoReq,
) -> Result<GitBranchInfo> {
Ok(crate::git_ext::cmd_git_branch_info(&req.dir).await?)
}
async fn cmd_git_worktree_status<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitWorktreeStatusReq) -> Result<GitWorktreeStatus> {
async fn cmd_git_worktree_status<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitWorktreeStatusReq,
) -> Result<GitWorktreeStatus> {
Ok(crate::git_ext::cmd_git_worktree_status(&req.dir).await?)
}
@@ -746,15 +987,25 @@ async fn cmd_git_log<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogReq) -> Resul
Ok(crate::git_ext::cmd_git_log(&req.dir).await?)
}
async fn cmd_git_log_for_file<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitLogForFileReq) -> Result<Vec<GitCommit>> {
async fn cmd_git_log_for_file<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitLogForFileReq,
) -> Result<Vec<GitCommit>> {
Ok(crate::git_ext::cmd_git_log_for_file(&req.dir, req.rela_path).await?)
}
async fn cmd_git_file_diff_for_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitFileDiffForCommitReq) -> Result<GitFileDiff> {
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
async fn cmd_git_file_diff_for_commit<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitFileDiffForCommitReq,
) -> Result<GitFileDiff> {
Ok(crate::git_ext::cmd_git_file_diff_for_commit(&req.dir, &req.commit_oid, req.rela_path)
.await?)
}
async fn cmd_git_initialize<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitInitializeReq) -> Result<()> {
async fn cmd_git_initialize<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitInitializeReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_initialize(&req.dir).await?)
}
@@ -778,11 +1029,17 @@ async fn cmd_git_pull<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullReq) -> Res
Ok(crate::git_ext::cmd_git_pull(&req.dir).await?)
}
async fn cmd_git_pull_force_reset<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullForceResetReq) -> Result<PullResult> {
async fn cmd_git_pull_force_reset<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitPullForceResetReq,
) -> Result<PullResult> {
Ok(crate::git_ext::cmd_git_pull_force_reset(&req.dir, &req.remote, &req.branch).await?)
}
async fn cmd_git_pull_merge<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitPullMergeReq) -> Result<PullResult> {
async fn cmd_git_pull_merge<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitPullMergeReq,
) -> Result<PullResult> {
Ok(crate::git_ext::cmd_git_pull_merge(&req.dir, &req.remote, &req.branch).await?)
}
@@ -794,27 +1051,47 @@ async fn cmd_git_unstage<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitUnstageReq)
Ok(crate::git_ext::cmd_git_unstage(&req.dir, req.rela_paths).await?)
}
async fn cmd_git_reset_changes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitResetChangesReq) -> Result<()> {
async fn cmd_git_reset_changes<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitResetChangesReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_reset_changes(&req.dir).await?)
}
async fn cmd_git_restore_files<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFilesReq) -> Result<()> {
async fn cmd_git_restore_files<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitRestoreFilesReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_restore_files(&req.dir, req.rela_paths).await?)
}
async fn cmd_git_restore_file_from_commit<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRestoreFileFromCommitReq) -> Result<()> {
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path).await?)
async fn cmd_git_restore_file_from_commit<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitRestoreFileFromCommitReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_restore_file_from_commit(&req.dir, &req.commit_oid, req.rela_path)
.await?)
}
async fn cmd_git_add_credential<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddCredentialReq) -> Result<()> {
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password).await?)
async fn cmd_git_add_credential<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitAddCredentialReq,
) -> Result<()> {
Ok(crate::git_ext::cmd_git_add_credential(&req.remote_url, &req.username, &req.password)
.await?)
}
async fn cmd_git_remotes<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRemotesReq) -> Result<Vec<GitRemote>> {
async fn cmd_git_remotes<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitRemotesReq,
) -> Result<Vec<GitRemote>> {
Ok(crate::git_ext::cmd_git_remotes(&req.dir).await?)
}
async fn cmd_git_add_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitAddRemoteReq) -> Result<GitRemote> {
async fn cmd_git_add_remote<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdGitAddRemoteReq,
) -> Result<GitRemote> {
Ok(crate::git_ext::cmd_git_add_remote(&req.dir, &req.name, &req.url).await?)
}
@@ -822,58 +1099,130 @@ async fn cmd_git_rm_remote<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGitRmRemoteRe
Ok(crate::git_ext::cmd_git_rm_remote(&req.dir, &req.name).await?)
}
async fn cmd_sync_calculate<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncCalculateReq) -> Result<Vec<SyncOp>> {
Ok(crate::sync_ext::cmd_sync_calculate(ctx.window.app_handle().clone(), &req.workspace_id, &req.sync_dir).await?)
async fn cmd_sync_calculate<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdSyncCalculateReq,
) -> Result<Vec<SyncOp>> {
Ok(crate::sync_ext::cmd_sync_calculate(
ctx.window.app_handle().clone(),
&req.workspace_id,
&req.sync_dir,
)
.await?)
}
async fn cmd_sync_calculate_fs<R: Runtime>(_ctx: ClientCtx<R>, req: CmdSyncCalculateFsReq) -> Result<Vec<SyncOp>> {
async fn cmd_sync_calculate_fs<R: Runtime>(
_ctx: ClientCtx<R>,
req: CmdSyncCalculateFsReq,
) -> Result<Vec<SyncOp>> {
Ok(crate::sync_ext::cmd_sync_calculate_fs(&req.dir).await?)
}
async fn cmd_sync_apply<R: Runtime>(ctx: ClientCtx<R>, req: CmdSyncApplyReq) -> Result<()> {
Ok(crate::sync_ext::cmd_sync_apply(ctx.window.app_handle().clone(), req.sync_ops, &req.sync_dir, &req.workspace_id).await?)
Ok(crate::sync_ext::cmd_sync_apply(
ctx.window.app_handle().clone(),
req.sync_ops,
&req.sync_dir,
&req.workspace_id,
)
.await?)
}
async fn cmd_ws_delete_connections<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsDeleteConnectionsReq) -> Result<()> {
async fn cmd_ws_delete_connections<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWsDeleteConnectionsReq,
) -> Result<()> {
Ok(yaak_commands::models::cmd_ws_delete_connections(ctx, req).await?)
}
async fn cmd_ws_send<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsSendReq) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_send(&req.connection_id, req.environment_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
async fn cmd_ws_send<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWsSendReq,
) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_send(
&req.connection_id,
req.environment_id.as_deref(),
ctx.window.app_handle().clone(),
ctx.window.clone(),
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
)
.await?)
}
async fn cmd_ws_close<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsCloseReq) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_close(&req.connection_id, ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
async fn cmd_ws_close<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWsCloseReq,
) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_close(
&req.connection_id,
ctx.window.app_handle().clone(),
ctx.window.clone(),
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
)
.await?)
}
async fn cmd_ws_connect<R: Runtime>(ctx: ClientCtx<R>, req: CmdWsConnectReq) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_connect(&req.request_id, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<Mutex<WebsocketManager>>()).await?)
async fn cmd_ws_connect<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdWsConnectReq,
) -> Result<WebsocketConnection> {
Ok(crate::ws_ext::cmd_ws_connect(
&req.request_id,
req.environment_id.as_deref(),
req.cookie_jar_id.as_deref(),
ctx.window.app_handle().clone(),
ctx.window.clone(),
ctx.window.app_handle().state::<Mutex<WebsocketManager>>(),
)
.await?)
}
async fn cmd_plugins_search<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsSearchReq) -> Result<PluginSearchResponse> {
async fn cmd_plugins_search<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginsSearchReq,
) -> Result<PluginSearchResponse> {
Ok(crate::plugins_ext::cmd_plugins_search(ctx.window.app_handle().clone(), &req.query).await?)
}
async fn cmd_plugins_install<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallReq) -> Result<()> {
async fn cmd_plugins_install<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginsInstallReq,
) -> Result<()> {
Ok(crate::plugins_ext::cmd_plugins_install(ctx.window.clone(), &req.name, req.version).await?)
}
async fn cmd_plugins_install_from_directory<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsInstallFromDirectoryReq) -> Result<Plugin> {
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory).await?)
async fn cmd_plugins_install_from_directory<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginsInstallFromDirectoryReq,
) -> Result<Plugin> {
Ok(crate::plugins_ext::cmd_plugins_install_from_directory(ctx.window.clone(), &req.directory)
.await?)
}
async fn cmd_plugins_uninstall<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginsUninstallReq) -> Result<Plugin> {
async fn cmd_plugins_uninstall<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginsUninstallReq,
) -> Result<Plugin> {
Ok(crate::plugins_ext::cmd_plugins_uninstall(&req.plugin_id, ctx.window.clone()).await?)
}
async fn cmd_plugin_init_errors<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInitErrorsReq) -> Result<Vec<(String, String)>> {
async fn cmd_plugin_init_errors<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdPluginInitErrorsReq,
) -> Result<Vec<(String, String)>> {
Ok(yaak_commands::plugins::cmd_plugin_init_errors(ctx, req).await?)
}
async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdatesReq) -> Result<PluginUpdatesResponse> {
async fn cmd_plugins_updates<R: Runtime>(
ctx: ClientCtx<R>,
_req: CmdPluginsUpdatesReq,
) -> Result<PluginUpdatesResponse> {
Ok(crate::plugins_ext::cmd_plugins_updates(ctx.window.app_handle().clone()).await?)
}
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
async fn cmd_plugins_update_all<R: Runtime>(
ctx: ClientCtx<R>,
_req: CmdPluginsUpdateAllReq,
) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
+1 -2
View File
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
use url::Url;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_crypto::manager::EncryptionManager;
use yaak_http::cookies::CookieStore;
use yaak_http::path_placeholders::apply_path_placeholders;
@@ -26,7 +27,6 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_commands::resolve::resolve_websocket_request;
use yaak_ws::{WebsocketManager, render_websocket_request};
pub async fn cmd_ws_send<R: Runtime>(
@@ -453,7 +453,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
Ok(connection)
}
/// Convert WS URL to HTTP URL for cookie filtering
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
fn convert_ws_url_to_http(ws_url: &Url) -> Url {
@@ -20,7 +20,9 @@ impl UpdateSource {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum ModelChangeEvent {
Upsert { created: bool },
Upsert {
created: bool,
},
/// A delete for a workspace implies deletion of every model in that
/// workspace — children are bulk-deleted without their own change rows or
/// events, and consumers must prune the subtree themselves (the frontend
-11
View File
@@ -331,17 +331,6 @@ export type ImportSource = {
lastImportedAt: string;
};
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
+4 -5
View File
@@ -29,11 +29,10 @@ use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesRe
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse,
GetFolderActionsResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose,
};
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_sse::sse::ServerSentEvent;
+5 -5
View File
@@ -13,8 +13,9 @@ pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
/// `dispatch` call frame, so it cannot borrow, and contexts are cheap clones
/// (handles and `Arc`s). Synchronous handlers wrap into this via `rpc_handler!`
/// with no visible change.
type HandlerFn<Ctx> =
Box<dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync>;
type HandlerFn<Ctx> = Box<
dyn Fn(Ctx, serde_json::Value) -> BoxFuture<Result<serde_json::Value, RpcError>> + Send + Sync,
>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
@@ -249,9 +250,8 @@ macro_rules! rpc_handler_async {
Box::new(|ctx, payload| {
Box::pin(async move {
let req = serde_json::from_value(payload).map_err($crate::RpcError::from)?;
let res = $f(ctx, req)
.await
.map_err(|e| $crate::RpcError { message: e.to_string() })?;
let res =
$f(ctx, req).await.map_err(|e| $crate::RpcError { message: e.to_string() })?;
serde_json::to_value(res).map_err($crate::RpcError::from)
})
})
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
+8 -2
View File
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
/**
* `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/**
* Where an import's contents came from, used to link the committed workspace back to it.
*/
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string, label: string, };
export type ImportOrigin = {
/**
* The absolute file path or URL the contents were read from.
*/
origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string },
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
export type ImportPlan = {
importer: string;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/**
* Stable source key for every model in `resources`, keyed by its planned ID.
*/
sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/**
* The model types an import plan can contain.
*/
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
@@ -0,0 +1,24 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+1 -1
View File
@@ -10,8 +10,8 @@ use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
pub mod cookies;
mod connection_or_tx;
pub mod cookies;
pub mod error;
pub mod migrate;
pub mod models;
+7 -3
View File
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
pub import_source_id: String,
pub source_key: String,
pub model_type: String,
pub model_id: String,
pub snapshot: String,
/// `None` once the user has decided not to import this key
#[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
}
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
source_key: r.get("source_key")?,
model_type: r.get("model_type")?,
model_id: r.get("model_id")?,
snapshot: r.get("snapshot")?,
content_hash: r.get("content_hash")?,
})
}
}
@@ -96,8 +96,7 @@ impl<'a> ClientDb<'a> {
let Some(response_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some()
{
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
if fs::remove_file(&path).is_ok() {
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot,
ImportSourceResourceIden::ContentHash,
])
.values_panic([
CurrentTimestamp.into(),
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(),
resource.model_type.as_str().into(),
resource.model_id.as_str().into(),
resource.snapshot.as_str().into(),
resource.model_id.clone().into(),
resource.content_hash.clone().into(),
])
.on_conflict(
OnConflict::columns([
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot,
ImportSourceResourceIden::ContentHash,
])
.to_owned(),
)
+1 -1
View File
@@ -24,8 +24,8 @@ mod websocket_events;
mod websocket_requests;
mod workspace_metas;
pub mod workspaces;
pub use model_changes::PersistedModelChange;
pub(crate) use duplicate_name::conflict_free_name;
pub use model_changes::PersistedModelChange;
const MAX_HISTORY_ITEMS: usize = 20;
+3 -3
View File
@@ -8,9 +8,9 @@ use crate::models::{
GrpcEventIden, GrpcRequest, GrpcRequestIden, HttpRequest, HttpRequestHeader, HttpRequestIden,
HttpResponse, HttpResponseEvent, HttpResponseEventIden, HttpResponseIden, ImportSource,
ImportSourceIden, ResolvedHttpRequestSettings, ResolvedSetting, SyncState, SyncStateIden,
WebsocketConnection,
WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden, WebsocketRequest,
WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta, WorkspaceMetaIden,
WebsocketConnection, WebsocketConnectionIden, WebsocketEvent, WebsocketEventIden,
WebsocketRequest, WebsocketRequestIden, Workspace, WorkspaceIden, WorkspaceMeta,
WorkspaceMetaIden,
};
use crate::util::UpdateSource;
use log::warn;
+37
View File
@@ -109,6 +109,28 @@ pub enum ImportDestination {
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
#[serde(default)]
pub level: ImportPlanWarningLevel,
}
/// Whether a plan's note is something to know or something to think twice about.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanWarningLevel {
#[default]
Info,
Warning,
}
impl ImportPlanWarning {
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Info }
}
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self { title: title.into(), detail: detail.into(), level: ImportPlanWarningLevel::Warning }
}
}
/// Where an import's contents came from, used to link the committed workspace back to it.
@@ -169,6 +191,16 @@ pub enum ImportPlanAction {
Unchanged,
KeepLocal,
Conflict,
/// Present in the source but previously turned down; selecting it imports it again
Ignored,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoIgnoredFolder,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
@@ -193,6 +225,11 @@ pub struct ImportPlanItem {
pub selected: bool,
#[ts(optional)]
pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
+13
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest
| HttpResponse
| HttpResponseEvent
| ImportSource
| KeyValue
| Plugin
| Settings
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+1 -1
View File
@@ -1,5 +1,4 @@
use crate::error::Result;
use yaak_models::blob_manager::BlobManager;
use crate::models::SyncModel;
use chrono::Utc;
use log::{info, warn};
@@ -11,6 +10,7 @@ use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use ts_rs::TS;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::models::{SyncState, WorkspaceMeta};
use yaak_models::util::{UpdateSource, get_workspace_export_resources};
+1
View File
@@ -9,6 +9,7 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true }
md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+1065 -215
View File
File diff suppressed because it is too large. Load diff
+13 -7
View File
@@ -668,9 +668,10 @@ mod tests {
let (query_manager, _temp_dir) = seed_query_manager();
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
);
let info_payload =
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
response_id: "rs_test".to_string(),
});
let info = handle_shared_plugin_event(
&query_manager,
&store,
@@ -716,9 +717,10 @@ mod tests {
#[test]
fn an_unreadable_response_body_becomes_an_error_reply() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
);
let payload =
InternalEventPayload::GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest {
response_id: "rs_never_persisted".to_string(),
});
let result = dispatch(
&query_manager,
&payload,
@@ -727,7 +729,11 @@ mod tests {
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
assert!(
resp.error.contains("rs_never_persisted"),
"unhelpful error: {}",
resp.error
)
}
other => panic!("unexpected missing-response result: {other:?}"),
}
+1 -1
View File
@@ -29,7 +29,7 @@
<li>Git-friendly plain-text project storage</li>
<li>Environment variables and template functions</li>
<li>Request chaining and dynamic values</li>
<li>OAuth 2.0, Bearer, Basic, Digest, API Key, AWS, JWT, and NTLM authentication</li>
<li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>
<li>Import from cURL, Postman, Insomnia, and OpenAPI</li>
<li>Extensible plugin system</li>
</ul>
+909 -487
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -25,7 +25,6 @@
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-digest",
"plugins/auth-jwt",
"plugins/auth-ntlm",
"plugins/auth-oauth2",
@@ -136,7 +135,9 @@
"yauzl": "^3.4.0"
},
"overrides": {
"@vitest/mocker": "^4.1.11",
"js-yaml": "^4.3.1",
"underscore": "^1.13.8",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
},
"packageManager": "npm@11.11.1"
+5 -23
View File
@@ -76,12 +76,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(
db,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
},
/* -------------------------------- app ---------------------------------- */
@@ -239,7 +234,6 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
@@ -268,16 +262,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: [
"WebSocket requests aren't available in the browser yet",
"websocket",
],
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
// Anything that needs files the page can't reach.
cmd_import_data: [
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
@@ -310,14 +298,8 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_tokens_to_string: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
+1 -1
View File
@@ -15,7 +15,7 @@
"test": "vp test --run tests"
},
"dependencies": {
"@faker-js/faker": "^10.1.0"
"@faker-js/faker": "^10.5.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
@@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] =
"faker.location.longitude",
"faker.location.nearbyGPSCoordinate",
"faker.location.ordinalDirection",
"faker.location.postalAddress",
"faker.location.secondaryAddress",
"faker.location.state",
"faker.location.street",
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^2.0.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.34",
"hono": "^4.13.5",
"zod": "^3.25.76"
},
"devDependencies": {
-56
View File
@@ -1,56 +0,0 @@
# Digest Authentication
An HTTP Digest Authentication plugin that implements
[RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616), with fallback to the
older [RFC 2617](https://datatracker.ietf.org/doc/html/rfc2617) and
[RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) behaviour that many
servers still speak.
## Overview
Digest Authentication proves you know a password without ever putting it on the
wire. The server issues a one-time `nonce`, and the client answers with a hash
over the credentials, the nonce and the request itself.
## How it works
Because the digest is computed over a server-issued nonce, the challenge has to
be fetched before the real request can be signed. On each send, the plugin:
1. Sends an unauthenticated probe of the same method and URL, carrying no
headers of its own so it cannot authorize anything
2. Reads the `WWW-Authenticate: Digest …` challenge from the `401` response
3. Computes the response hash and returns the `Authorization` header
## Configuration
- **Username**: Username or user identifier
- **Password**: Password or authentication token
- **Realm** (advanced): Only needed when the server offers more than one realm.
Leave it empty and the first challenge the server prefers is used.
## Supported challenges
| Feature | Supported |
| ---------- | ------------------------------------------------------------- |
| Algorithm | `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess` |
| `qop` | `auth`, `auth-int`, and challenges that omit `qop` (RFC 2069) |
| `opaque` | Echoed back when the server sends one |
| `userhash` | Declined with `userhash=false` |
`auth-int` is used when the server offers it and Yaak has the request body in
hand. Bodies that are streamed from disk or above the size Yaak passes to auth
plugins aren't available to hash, so those requests use `auth` instead.
Credentials are normalized to Unicode NFC before hashing, per RFC 7616 §4.
Usernames outside ASCII are sent as an RFC 5987 extended value (`username*`).
## Troubleshooting
- **Server did not offer Digest authentication**: The endpoint answered the probe
with a different scheme, or with no `WWW-Authenticate` header at all. Check the
URL, and whether the endpoint requires auth in the first place.
- **Unsupported Digest algorithm**: The server asked for an algorithm this plugin
doesn't implement, such as `SHA-512-256`.
- **401 Unauthorized**: Verify the username and password. If the server offers
several realms, set the Realm field to the one your account belongs to.
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@yaak/auth-digest",
"displayName": "Digest Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using HTTP Digest authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-digest"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}
-294
View File
@@ -1,294 +0,0 @@
import { createHash } from "node:crypto";
/** A single challenge from a `WWW-Authenticate` header. */
export interface AuthChallenge {
scheme: string;
params: Record<string, string>;
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
token68?: string;
}
export interface DigestChallenge {
realm: string;
nonce: string;
opaque?: string;
qop?: string[];
/** Echoed back verbatim, so it must keep the server's own spelling. */
algorithm?: string;
stale: boolean;
userhash: boolean;
}
export interface DigestAuthorizationOptions {
username: string;
password: string;
method: string;
uri: string;
body: string | null;
challenge: DigestChallenge;
cnonce: string;
nc: number;
}
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
const SUPPORTED_QOPS = ["auth", "auth-int"];
/**
* Split a header value on commas that aren't inside a quoted string. Both
* challenges and their parameters are comma-separated, so this yields a flat
* list that {@link parseChallenges} re-groups.
*/
function splitOnCommas(value: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < value.length; i++) {
const char = value[i]!;
if (quoted && char === "\\" && i + 1 < value.length) {
current += char + value[++i]!;
} else if (char === '"') {
quoted = !quoted;
current += char;
} else if (char === "," && !quoted) {
parts.push(current);
current = "";
} else {
current += char;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter((p) => p !== "");
}
function unquote(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
}
return trimmed;
}
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
const challenges: AuthChallenge[] = [];
for (const headerValue of headerValues) {
let current: AuthChallenge | null = null;
for (const part of splitOnCommas(headerValue)) {
const param = PARAM_RE.exec(part);
if (param != null && current != null) {
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
continue;
}
const scheme = SCHEME_RE.exec(part);
if (scheme == null) continue;
current = { scheme: scheme[1]!, params: {} };
challenges.push(current);
const rest = scheme[2]?.trim();
if (rest == null || rest === "") continue;
if (TOKEN68_RE.test(rest)) {
current.token68 = rest;
continue;
}
const firstParam = PARAM_RE.exec(rest);
if (firstParam != null) {
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
}
}
}
return challenges;
}
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
const qop = params.qop
?.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean);
return {
realm: params.realm ?? "",
nonce: params.nonce ?? "",
opaque: params.opaque,
qop: qop == null || qop.length === 0 ? undefined : qop,
algorithm: params.algorithm,
stale: params.stale?.toLowerCase() === "true",
userhash: params.userhash?.toLowerCase() === "true",
};
}
/**
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
* spelling some servers use. Returns null for anything else.
*/
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
const value = (algorithm ?? "MD5").trim().toLowerCase();
const sess = value.endsWith("-sess");
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
if (base === "md5") return { hash: "md5", sess };
if (base === "sha256") return { hash: "sha256", sess };
return null;
}
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
return new Error(
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
);
}
function unsupportedQopError(qop: string[]): Error {
return new Error(
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
);
}
/**
* Everything that would stop this challenge from being answered, or null if it
* can be. Selection asks the whole question at once so a challenge that fails
* on any count is passed over for the next one the server offered, rather than
* chosen and then failed on later.
*/
function challengeProblem(challenge: DigestChallenge): Error | null {
if (resolveAlgorithm(challenge.algorithm) == null) {
return unsupportedAlgorithmError(challenge.algorithm);
}
if (challenge.nonce === "") {
return new Error('Digest challenge is missing the required "nonce" parameter');
}
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
return unsupportedQopError(challenge.qop);
}
return null;
}
/**
* Pick the challenge to answer. Servers list challenges strongest-first
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
*/
export function selectDigestChallenge(
challenges: AuthChallenge[],
realm?: string,
): DigestChallenge {
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
if (digestChallenges.length === 0) {
const offered = challenges.map((c) => c.scheme).join(", ");
throw new Error(
offered === ""
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
: `Server did not offer Digest authentication. It offered: ${offered}`,
);
}
const inRealm =
realm == null || realm === ""
? digestChallenges
: digestChallenges.filter((c) => c.params.realm === realm);
if (inRealm.length === 0) {
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
}
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
const answerable = candidates.find((c) => challengeProblem(c) == null);
if (answerable == null) throw challengeProblem(candidates[0]!);
return answerable;
}
/**
* Prefer `auth-int` only when the body is in hand, since its digest covers the
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
* didn't hand over (too large, or streamed from a file), and the two are
* indistinguishable from here. When `auth-int` is all the server offers it is
* still used, hashing the empty body: that is exactly right for the empty case
* and no worse than refusing outright for the other.
*/
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
if (qop.includes("auth")) return "auth";
throw unsupportedQopError(qop);
}
function quote(value: string): string {
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
function encodeExtended(value: string): string {
const encoded = encodeURIComponent(value).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `UTF-8''${encoded}`;
}
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
const { method, uri, body, challenge, cnonce, nc } = options;
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
// a combining sequence digests the same as its precomposed spelling.
const username = options.username.normalize("NFC");
const password = options.password.normalize("NFC");
const algorithm = resolveAlgorithm(challenge.algorithm);
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
const ncHex = nc.toString(16).padStart(8, "0");
const secret = hash(`${username}:${challenge.realm}:${password}`);
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
const ha2 =
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
// Without qop the server speaks RFC 2069, where the client contributes nothing
// to the digest and so must not send cnonce, nc or qop back.
const response =
qop == null
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
const params: string[] = [];
params.push(
/^[\x20-\x7E]*$/.test(username)
? `username=${quote(username)}`
: `username*=${encodeExtended(username)}`,
);
params.push(`realm=${quote(challenge.realm)}`);
params.push(`uri=${quote(uri)}`);
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
params.push(`nonce=${quote(challenge.nonce)}`);
if (qop != null) {
params.push(`nc=${ncHex}`);
params.push(`cnonce=${quote(cnonce)}`);
params.push(`qop=${qop}`);
}
params.push(`response=${quote(response)}`);
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
if (challenge.userhash) params.push("userhash=false");
return `Digest ${params.join(", ")}`;
}
/** The origin-form request-target the digest is computed over. */
export function requestTarget(url: string): string {
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
const parsed = new URL(absolute);
return `${parsed.pathname}${parsed.search}`;
}
-75
View File
@@ -1,75 +0,0 @@
import { randomBytes } from "node:crypto";
import type { PluginDefinition } from "@yaakapp/api";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
} from "./digest";
export const plugin: PluginDefinition = {
authentication: {
name: "digest",
label: "Digest Auth",
shortLabel: "Digest",
args: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "text",
name: "realm",
label: "Realm",
optional: true,
description: "Only needed when the server offers more than one realm",
},
],
},
],
async onApply(ctx, { values, method, url, body }) {
const username = values.username ? String(values.username) : "";
const password = values.password ? String(values.password) : "";
const realm = values.realm ? String(values.realm) : undefined;
// Digest needs a server-issued nonce, so the challenge has to be provoked
// before the real request can be signed. The probe carries nothing but the
// method and URL: a cookie or an API key header would let it authorize the
// very operation it is only meant to ask permission for, and there is no
// telling a routing header from a credential by looking at it.
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
const headerValues = httpResponse.headers
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.map((h) => h.value);
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
const value = buildDigestAuthorization({
username,
password,
method,
uri: requestTarget(url),
body,
challenge,
cnonce: randomBytes(16).toString("hex"),
nc: 1,
});
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};
-390
View File
@@ -1,390 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
toDigestChallenge,
} from "../src/digest";
function paramOf(header: string, name: string): string | undefined {
return parseChallenges([header])[0]?.params[name];
}
describe("parseChallenges", () => {
test("parses quoted and unquoted parameters", () => {
expect(parseChallenges(['Digest realm="test", algorithm=MD5, stale=TRUE'])).toEqual([
{ scheme: "Digest", params: { realm: "test", algorithm: "MD5", stale: "TRUE" } },
]);
});
test("keeps commas and escapes inside quoted values", () => {
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "qop")).toEqual(
"auth,auth-int",
);
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "realm")).toEqual(
'a "quoted" realm',
);
});
test("tolerates whitespace around the equals sign", () => {
expect(paramOf('Digest realm = "test"', "realm")).toEqual("test");
});
test("splits multiple challenges in a single header", () => {
expect(parseChallenges(['Basic realm="a", Digest realm="b", nonce="n"'])).toEqual([
{ scheme: "Basic", params: { realm: "a" } },
{ scheme: "Digest", params: { realm: "b", nonce: "n" } },
]);
});
test("collects challenges across repeated headers", () => {
expect(parseChallenges(['Digest realm="a"', "Negotiate"]).map((c) => c.scheme)).toEqual([
"Digest",
"Negotiate",
]);
});
test("captures token68 credentials rather than reading them as parameters", () => {
expect(parseChallenges(["NTLM TlRMTVNTUAACAAAAAA=="])).toEqual([
{ scheme: "NTLM", params: {}, token68: "TlRMTVNTUAACAAAAAA==" },
]);
});
test("lower-cases parameter names", () => {
expect(paramOf('Digest Realm="test", NONCE="n"', "realm")).toEqual("test");
expect(paramOf('Digest Realm="test", NONCE="n"', "nonce")).toEqual("n");
});
});
describe("toDigestChallenge", () => {
test("splits qop and reads the boolean flags", () => {
const challenge = toDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=" auth , AUTH-INT ", stale=true'])[0]!
.params,
);
expect(challenge.qop).toEqual(["auth", "auth-int"]);
expect(challenge.stale).toBe(true);
expect(challenge.userhash).toBe(false);
});
test("treats a missing qop as absent rather than empty", () => {
expect(toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])[0]!.params).qop).toBe(
undefined,
);
});
test("reads userhash", () => {
expect(
toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n", userhash=TRUE'])[0]!.params)
.userhash,
).toBe(true);
});
});
describe("selectDigestChallenge", () => {
const md5 = 'Digest realm="a", nonce="n1", algorithm=MD5';
const sha = 'Digest realm="b", nonce="n2", algorithm=SHA-256';
test("takes the first Digest challenge the server prefers", () => {
expect(selectDigestChallenge(parseChallenges([sha, md5])).nonce).toEqual("n2");
});
test("skips challenges whose algorithm is not supported", () => {
const unsupported = 'Digest realm="c", nonce="n0", algorithm=SHA-512-256';
expect(selectDigestChallenge(parseChallenges([unsupported, md5])).nonce).toEqual("n1");
});
test("skips challenges whose qop cannot be answered", () => {
const unanswerable = 'Digest realm="c", nonce="n0", qop="auth-conf"';
expect(selectDigestChallenge(parseChallenges([unanswerable, md5])).nonce).toEqual("n1");
});
test("skips challenges that carry no nonce", () => {
expect(selectDigestChallenge(parseChallenges(['Digest realm="c"', md5])).nonce).toEqual("n1");
});
test("reports the first challenge's problem when none can be answered", () => {
expect(() =>
selectDigestChallenge(
parseChallenges(['Digest realm="c", nonce="n", qop="auth-conf"', 'Digest realm="d"']),
),
).toThrow("Unsupported Digest qop: auth-conf");
});
test("matches the case-insensitive scheme name", () => {
expect(selectDigestChallenge(parseChallenges(['digest realm="a", nonce="n1"'])).nonce).toEqual(
"n1",
);
});
test("selects by realm when one is given", () => {
expect(selectDigestChallenge(parseChallenges([sha, md5]), "a").nonce).toEqual("n1");
});
test("errors when the requested realm is not offered", () => {
expect(() => selectDigestChallenge(parseChallenges([sha, md5]), "nope")).toThrow(
'Server did not offer a Digest realm named "nope". It offered: "b", "a"',
);
});
test("errors when the server offers no Digest challenge", () => {
expect(() => selectDigestChallenge(parseChallenges(['Basic realm="a"', "Negotiate"]))).toThrow(
"Server did not offer Digest authentication. It offered: Basic, Negotiate",
);
});
test("errors when the response carries no challenge at all", () => {
expect(() => selectDigestChallenge(parseChallenges([]))).toThrow(
"no WWW-Authenticate header in the response",
);
});
test("errors when no offered algorithm is supported", () => {
expect(() =>
selectDigestChallenge(
parseChallenges(['Digest realm="c", nonce="n", algorithm=SHA-512-256']),
),
).toThrow("Unsupported Digest algorithm: SHA-512-256");
});
test("errors when the challenge has no nonce", () => {
expect(() => selectDigestChallenge(parseChallenges(['Digest realm="c"']))).toThrow(
'Digest challenge is missing the required "nonce" parameter',
);
});
});
// https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
describe("RFC 7616 §3.9.1 worked example", () => {
const headers = [
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=SHA-256, ' +
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, ' +
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
];
const common = {
username: "Mufasa",
password: "Circle of Life",
method: "GET",
uri: "/dir/index.html",
body: null,
cnonce: "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
nc: 1,
};
test("SHA-256", () => {
expect(
buildDigestAuthorization({
...common,
challenge: selectDigestChallenge(parseChallenges(headers)),
}),
).toEqual(
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
'algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
);
});
test("MD5", () => {
expect(
buildDigestAuthorization({
...common,
challenge: selectDigestChallenge(parseChallenges([headers[1]!])),
}),
).toEqual(
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
'algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
'response="8ca523f5e9506fed4657c9700eebdbec", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
);
});
});
// https://datatracker.ietf.org/doc/html/rfc2617#section-3.5
describe("RFC 2617 §3.5 worked example", () => {
test("MD5 with qop=auth", () => {
const challenge = selectDigestChallenge(
parseChallenges([
'Digest realm="testrealm@host.com", qop="auth,auth-int", ' +
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
]),
);
expect(
buildDigestAuthorization({
username: "Mufasa",
password: "Circle Of Life",
method: "GET",
uri: "/dir/index.html",
body: null,
challenge,
cnonce: "0a4f113b",
nc: 1,
}),
).toContain('response="6629fae49393a05397450978507c4ef1"');
});
});
describe("buildDigestAuthorization", () => {
const base = {
username: "user",
password: "pass",
method: "POST",
uri: "/api",
body: null as string | null,
cnonce: "abc123",
nc: 1,
};
test("omits the client's contribution when the server offers no qop", () => {
const header = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
});
expect(header).not.toContain("qop=");
expect(header).not.toContain("cnonce=");
expect(header).not.toContain("nc=");
// MD5(HA1:nonce:HA2), per RFC 2069.
expect(header).toContain('response="24644771b8983deed818b83aeb3ac381"');
});
test("omits algorithm when the challenge did not name one", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).not.toContain("algorithm=");
});
test("uses auth-int over the body when the server offers it", () => {
const header = buildDigestAuthorization({
...base,
body: '{"a":1}',
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
),
});
expect(header).toContain("qop=auth-int");
});
test("falls back to auth when no body was handed over", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
),
}),
).toContain("qop=auth");
});
test("uses auth-int over an empty body when it is the only qop offered", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth-int"']),
),
}),
).toContain("qop=auth-int");
});
test("rejects a qop it cannot compute", () => {
expect(() =>
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth-conf"']),
),
}),
).toThrow("Unsupported Digest qop: auth-conf");
});
test("mixes the cnonce into HA1 for -sess algorithms", () => {
const sess = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5-sess']),
),
});
const plain = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5']),
),
});
expect(sess).toContain("algorithm=MD5-sess");
expect(sess).not.toEqual(plain);
});
test("declines userhash when the server advertises it", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, userhash=true']),
),
}),
).toContain("userhash=false");
});
test("escapes quotes in the credentials it echoes back", () => {
expect(
buildDigestAuthorization({
...base,
username: 'a"b',
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).toContain('username="a\\"b"');
});
test("normalizes credentials to NFC before hashing", () => {
const challenge = selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"']));
// "Jäsøn" spelled with a combining diaeresis rather than a precomposed "ä".
const decomposed = buildDigestAuthorization({
...base,
username: "Ja\u0308s\u00f8n",
password: "pa\u0308ss",
challenge,
});
const precomposed = buildDigestAuthorization({
...base,
username: "J\u00e4s\u00f8n",
password: "p\u00e4ss",
challenge,
});
expect(decomposed).toEqual(precomposed);
});
test("sends a non-ASCII username as an RFC 5987 extended value", () => {
expect(
buildDigestAuthorization({
...base,
username: "Jäsøn Doe",
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).toContain("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe");
});
});
describe("requestTarget", () => {
test("keeps the path and query", () => {
expect(requestTarget("https://example.org/dir/index.html?a=b&c=d")).toEqual(
"/dir/index.html?a=b&c=d",
);
});
test("uses a bare slash when there is no path", () => {
expect(requestTarget("https://example.org")).toEqual("/");
});
test("handles a URL with no scheme", () => {
expect(requestTarget("localhost:8080/thing")).toEqual("/thing");
});
});
-375
View File
@@ -1,375 +0,0 @@
import { createHash } from "node:crypto";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import type { Context } from "@yaakapp/api";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import { plugin } from "../src";
function apply(ctx: Context, values: Record<string, string>, over: Partial<ApplyArgs> = {}) {
return plugin.authentication!.onApply(ctx, {
values,
headers: [],
url: "https://example.org/dir/index.html?a=b",
method: "GET",
body: null,
contextId: "ctx",
...over,
});
}
type ApplyArgs = Parameters<NonNullable<typeof plugin.authentication>["onApply"]>[1];
function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
ctx: Context;
send: ReturnType<typeof vi.fn>;
} {
const send = vi.fn().mockResolvedValue({ httpResponse: { headers } });
return { ctx: { httpRequest: { send } } as unknown as Context, send };
}
describe("auth-digest onApply", () => {
test("probes with the same method and URL, without credentials or body", async () => {
const { ctx, send } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
await apply(ctx, { username: "user", password: "pass" }, { method: "POST", body: "hello" });
expect(send).toHaveBeenCalledWith({
httpRequest: { method: "POST", url: "https://example.org/dir/index.html?a=b" },
});
});
test("keeps credential-bearing headers off the probe", async () => {
const { ctx, send } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
await apply(
ctx,
{ username: "user", password: "pass" },
{
method: "DELETE",
headers: [
{ name: "Cookie", value: "session=abc" },
{ name: "X-Api-Key", value: "secret" },
{ name: "Authorization", value: "Bearer stale" },
],
},
);
expect(send).toHaveBeenCalledWith({
httpRequest: { method: "DELETE", url: "https://example.org/dir/index.html?a=b" },
});
});
test("signs the request-target rather than the whole URL", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
const result = await apply(ctx, { username: "user", password: "pass" });
expect(result.setHeaders?.[0]?.name).toEqual("Authorization");
expect(result.setHeaders?.[0]?.value).toContain('uri="/dir/index.html?a=b"');
});
test("uses a fresh cnonce on every apply", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
const first = await apply(ctx, { username: "user", password: "pass" });
const second = await apply(ctx, { username: "user", password: "pass" });
expect(first.setHeaders?.[0]?.value).not.toEqual(second.setHeaders?.[0]?.value);
});
test("treats missing credentials as empty strings", async () => {
const { ctx } = ctxRespondingWith([
{ name: "www-authenticate", value: 'Digest realm="r", nonce="n"' },
]);
expect((await apply(ctx, {})).setHeaders?.[0]?.value).toContain('username=""');
});
test("fails clearly when the server does not offer Digest", async () => {
const { ctx } = ctxRespondingWith([{ name: "WWW-Authenticate", value: 'Basic realm="r"' }]);
await expect(apply(ctx, { username: "user", password: "pass" })).rejects.toThrow(
"Server did not offer Digest authentication. It offered: Basic",
);
});
test("selects the realm the user configured", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="one", nonce="n1"' },
{ name: "WWW-Authenticate", value: 'Digest realm="two", nonce="n2"' },
]);
expect(
(await apply(ctx, { username: "user", password: "pass", realm: "two" })).setHeaders?.[0]
?.value,
).toContain('nonce="n2"');
});
});
function nextComma(value: string, from: number): number {
const index = value.indexOf(",", from);
return index < 0 ? value.length : index;
}
/**
* Read `Digest name=value, name="value"` credentials by scanning, rather than
* with a global regex: a repeated character class in front of the `=` backtracks
* quadratically over a long run of the characters it accepts.
*/
function parseCredentials(header: string): Record<string, string> {
const params: Record<string, string> = {};
let i = header.indexOf(" ") + 1;
while (i < header.length) {
const equals = header.indexOf("=", i);
if (equals < 0) break;
const name = header.slice(i, equals).trim().toLowerCase();
i = equals + 1;
let value = "";
if (header[i] === '"') {
for (i++; i < header.length && header[i] !== '"'; i++) {
if (header[i] === "\\") i++;
value += header[i];
}
i++;
} else {
const end = nextComma(header, i);
value = header.slice(i, end).trim();
i = end;
}
params[name] = value;
i = nextComma(header, i) + 1;
}
return params;
}
/**
* A minimally correct Digest server, hashing inline rather than through the
* plugin's own helpers so the round trip can't agree with itself on a mistake.
*/
function startDigestServer(config: {
username: string;
password: string;
realm: string;
nonce: string;
algorithm?: string;
qop?: string;
/** Offer a second, unrelated realm ahead of the real one. */
decoyRealm?: string;
}): Promise<{ url: string; close: () => Promise<void> }> {
const hashName = (config.algorithm ?? "MD5").toLowerCase().startsWith("sha-256")
? "sha256"
: "md5";
const sess = (config.algorithm ?? "").toLowerCase().endsWith("-sess");
const hash = (value: string) => createHash(hashName).update(value, "utf8").digest("hex");
const server: Server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const authorization = req.headers.authorization;
if (authorization == null || !authorization.startsWith("Digest ")) {
const challenge = [
`Digest realm="${config.realm}"`,
`nonce="${config.nonce}"`,
`algorithm=${config.algorithm ?? "MD5"}`,
config.qop == null ? null : `qop="${config.qop}"`,
'opaque="0p4qu3"',
]
.filter(Boolean)
.join(", ");
res.setHeader(
"WWW-Authenticate",
config.decoyRealm == null
? [challenge]
: [
`Digest realm="${config.decoyRealm}", nonce="wrong-nonce", algorithm=MD5`,
challenge,
],
);
res.writeHead(401).end("unauthorized");
return;
}
const params = parseCredentials(authorization);
const secret = hash(`${config.username}:${config.realm}:${config.password}`);
const ha1 = sess ? hash(`${secret}:${params.nonce}:${params.cnonce}`) : secret;
const ha2 =
params.qop === "auth-int"
? hash(`${req.method}:${params.uri}:${hash(Buffer.concat(chunks).toString("utf8"))}`)
: hash(`${req.method}:${params.uri}`);
const expected =
params.qop == null
? hash(`${ha1}:${params.nonce}:${ha2}`)
: hash(`${ha1}:${params.nonce}:${params.nc}:${params.cnonce}:${params.qop}:${ha2}`);
const ok =
params.username === config.username &&
params.realm === config.realm &&
params.nonce === config.nonce &&
params.uri === req.url &&
params.opaque === "0p4qu3" &&
params.response === expected;
res.writeHead(ok ? 200 : 401).end(ok ? "welcome" : "denied");
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise<void>((done) => server.close(() => done())),
});
});
});
}
/** Sends for real, so the plugin sees the headers a live server actually returns. */
function realContext(): Context {
return {
httpRequest: {
async send({ httpRequest }: { httpRequest: { method?: string; url?: string } }) {
const res = await fetch(httpRequest.url!, { method: httpRequest.method });
await res.text();
return {
httpResponse: {
headers: [...res.headers].map(([name, value]) => ({ name, value })),
},
};
},
},
} as unknown as Context;
}
describe("auth-digest against a live server", () => {
let close: (() => Promise<void>) | null = null;
afterEach(async () => {
await close?.();
close = null;
});
for (const algorithm of ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"]) {
test(`authenticates with algorithm=${algorithm}`, async () => {
const server = await startDigestServer({
username: "Mufasa",
password: "Circle of Life",
realm: "http-auth@example.org",
nonce: "7ypf/xlj9XXwfDPEoM4URrv",
algorithm,
qop: "auth",
});
close = server.close;
const url = `${server.url}/dir/index.html?a=b`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "Mufasa", password: "Circle of Life" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, {
headers: { Authorization: result.setHeaders![0]!.value },
});
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
}
test("authenticates a POST body with qop=auth-int", async () => {
const body = '{"hello":"world"}';
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "api@example.org",
nonce: "n0nc3",
algorithm: "SHA-256",
qop: "auth,auth-int",
});
close = server.close;
const url = `${server.url}/submit`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass" },
headers: [],
url,
method: "POST",
body,
contextId: "ctx",
});
expect(result.setHeaders![0]!.value).toContain("qop=auth-int");
const res = await fetch(url, {
method: "POST",
body,
headers: { Authorization: result.setHeaders![0]!.value },
});
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
test("authenticates against an RFC 2069 server that offers no qop", async () => {
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "legacy@example.org",
nonce: "old-nonce",
});
close = server.close;
const url = `${server.url}/legacy`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
test("picks the configured realm out of several the server offers", async () => {
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "second@example.org",
nonce: "n0nc3",
qop: "auth",
decoyRealm: "first@example.org",
});
close = server.close;
const url = `${server.url}/multi`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass", realm: "second@example.org" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
expect(res.status).toEqual(200);
});
});
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}
+1 -1
View File
@@ -9,7 +9,7 @@
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}