mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e79bf9d0d | ||
|
|
03029cd372 | ||
|
|
446596989e | ||
|
|
b7f749cc9d | ||
|
|
3ae56a96d0 | ||
|
|
631f6ac9f7 |
No files matched your search
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
||||||
- API keys can be limited to reading or running actions
|
- API keys can be limited to reading or running actions, when an admin makes them
|
||||||
|
- A click on a host can open its host page, when a plugin offers one
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||||
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||||
|
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
|
||||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.11.0",
|
"version": "26.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.11.0",
|
"version": "26.11.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.11.0",
|
"version": "26.11.1",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.25
|
||||||
|
|
||||||
|
Plugin API 1.14.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Host click behavior `hostPage` opens a host page when a plugin offers one
|
||||||
|
- `createApiKey` takes scopes, to limit a key to actions
|
||||||
|
|
||||||
|
## 1.0.24
|
||||||
|
|
||||||
|
Plugin API 1.14.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `app.registerHostSection` adds a card to the host page
|
||||||
|
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
|
||||||
|
|
||||||
## 1.0.23
|
## 1.0.23
|
||||||
|
|
||||||
Plugin API 1.14.0.
|
Plugin API 1.14.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.23 | Plugin API 1.14.0
|
SDK 1.0.25 | Plugin API 1.14.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.23",
|
"version": "1.0.25",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
|
|||||||
/** A control in the host editor's toolbar. */
|
/** A control in the host editor's toolbar. */
|
||||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||||
|
|
||||||
|
export interface HostSectionProps {
|
||||||
|
host: PluginHostRecord;
|
||||||
|
isVisible: boolean;
|
||||||
|
shell: ShellApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostSectionContribution {
|
||||||
|
/** Declare it in manifest contributes.hostSections too. */
|
||||||
|
id: string;
|
||||||
|
titleKey: string;
|
||||||
|
/** Lower sorts first. */
|
||||||
|
order?: number;
|
||||||
|
when?: (host: PluginHostRecord) => boolean;
|
||||||
|
component: ComponentType<HostSectionProps>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostContextMenuItemContribution {
|
export interface HostContextMenuItemContribution {
|
||||||
id: string;
|
id: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -1172,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerHostContextMenuItem: (
|
registerHostContextMenuItem: (
|
||||||
item: HostContextMenuItemContribution,
|
item: HostContextMenuItemContribution,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
|
/**
|
||||||
|
* A card on the host page. Core 26.11.1 and newer; call it as
|
||||||
|
* app.registerHostSection?.(...) if your plugin allows older cores.
|
||||||
|
*/
|
||||||
|
registerHostSection: (section: HostSectionContribution) => Disposer;
|
||||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||||
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
||||||
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
|
|||||||
HostProtocolDef (type): HostProtocolDef
|
HostProtocolDef (type): HostProtocolDef
|
||||||
HostProtocols (type): HostProtocols
|
HostProtocols (type): HostProtocols
|
||||||
HostRowFields (type): HostRowFields
|
HostRowFields (type): HostRowFields
|
||||||
|
HostSectionDef (type): HostSectionDef
|
||||||
HostSidebarFilterState (type): HostSidebarFilterState
|
HostSidebarFilterState (type): HostSidebarFilterState
|
||||||
HostSidebarPreferences (type): HostSidebarPreferences
|
HostSidebarPreferences (type): HostSidebarPreferences
|
||||||
|
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
|
||||||
HostTrayTrigger (type): HostTrayTrigger
|
HostTrayTrigger (type): HostTrayTrigger
|
||||||
HttpsSettings (type): HttpsSettings
|
HttpsSettings (type): HttpsSettings
|
||||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||||
@@ -354,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
|
|||||||
clearLocalAdaptivePreferences (value): () => void
|
clearLocalAdaptivePreferences (value): () => void
|
||||||
cn (value): (...inputs: ClassValue[]) => string
|
cn (value): (...inputs: ClassValue[]) => string
|
||||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
|
||||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||||
@@ -457,6 +459,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
|||||||
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||||
|
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
|
||||||
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||||
invalidateHostsAndStatusCaches (value): () => void
|
invalidateHostsAndStatusCaches (value): () => void
|
||||||
@@ -475,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
|
|||||||
linkedServerUrl (value): () => Promise<string>
|
linkedServerUrl (value): () => Promise<string>
|
||||||
listHostActions (value): () => HostActionDef[]
|
listHostActions (value): () => HostActionDef[]
|
||||||
listHostProtocols (value): () => HostProtocolDef[]
|
listHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
listHostSections (value): () => HostSectionDef[]
|
||||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
@@ -488,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void
|
|||||||
normalizeLanguageCode (value): (language?: string) => string
|
normalizeLanguageCode (value): (language?: string) => string
|
||||||
notifySyncChanged (value): () => void
|
notifySyncChanged (value): () => void
|
||||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||||
|
operationsApi (value): AxiosInstance
|
||||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||||
parseServerTime (value): (ts: string) => Date
|
parseServerTime (value): (ts: string) => Date
|
||||||
@@ -598,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
|||||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||||
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||||
useHostProtocols (value): () => HostProtocolDef[]
|
useHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
useHostSections (value): () => HostSectionDef[]
|
||||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||||
useHostStatusIndicators (value): () => HostBadgeDef[]
|
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||||
useInstalledPlugins (value): () => PluginSummary[]
|
useInstalledPlugins (value): () => PluginSummary[]
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
|||||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||||
|
|
||||||
const ALLOWED = [
|
const ALLOWED = [
|
||||||
|
"useHostSections",
|
||||||
|
"operationsApi",
|
||||||
|
"listHostSections",
|
||||||
|
"hostSectionsFor",
|
||||||
|
"HostTabEmbed",
|
||||||
|
"HostSectionDef",
|
||||||
"ACCENT_PRESET_COLORS",
|
"ACCENT_PRESET_COLORS",
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"AccessRecord",
|
"AccessRecord",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** JSON list, or null for full access. */
|
||||||
|
scopes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiKeyRepository {
|
export class ApiKeyRepository {
|
||||||
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
|
|||||||
expiresAt: apiKeys.expiresAt,
|
expiresAt: apiKeys.expiresAt,
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
isActive: apiKeys.isActive,
|
isActive: apiKeys.isActive,
|
||||||
|
scopes: apiKeys.scopes,
|
||||||
})
|
})
|
||||||
.from(apiKeys)
|
.from(apiKeys)
|
||||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: Optional expiration date. Null means the key never expires.
|
* description: Optional expiration date. Null means the key never expires.
|
||||||
|
* scopes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* enum: [operations:read, operations:run]
|
||||||
|
* description: Limits the key to actions only. Leave out for full access.
|
||||||
* responses:
|
* responses:
|
||||||
* 201:
|
* 201:
|
||||||
* description: API key created. Contains the full token (shown only once).
|
* description: API key created. Contains the full token (shown only once).
|
||||||
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
*/
|
*/
|
||||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
|
||||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||||
const userRepository = createCurrentUserRepository();
|
const userRepository = createCurrentUserRepository();
|
||||||
|
|
||||||
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAtValue = parsed.toISOString();
|
expiresAtValue = parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scopesValue: string[] | null = null;
|
||||||
|
if (scopes !== undefined && scopes !== null) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(scopes) ||
|
||||||
|
scopes.some(
|
||||||
|
(scope) =>
|
||||||
|
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Unknown API key scope" });
|
||||||
|
}
|
||||||
|
scopesValue =
|
||||||
|
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
|
||||||
|
}
|
||||||
|
|
||||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||||
const tokenPrefix = rawToken.substring(0, 12);
|
const tokenPrefix = rawToken.substring(0, 12);
|
||||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||||
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actorId = (req as AuthenticatedRequest).userId;
|
const actorId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
details: JSON.stringify({
|
details: JSON.stringify({
|
||||||
targetUserId,
|
targetUserId,
|
||||||
targetUsername: targetUser.username,
|
targetUsername: targetUser.username,
|
||||||
|
scopes: scopesValue,
|
||||||
}),
|
}),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
tokenPrefix,
|
tokenPrefix,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
|
scopes: scopesValue,
|
||||||
token: rawToken,
|
token: rawToken,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
||||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
|
// approved: a person approved this exact request in the plugin's own UI.
|
||||||
|
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
|
||||||
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
||||||
|
|
||||||
/** A person is the check when the request came from something else. */
|
/** A person is the check when the request came from something else. */
|
||||||
|
|||||||
@@ -451,6 +451,15 @@ describe("policy", () => {
|
|||||||
expect(outcome.status).toBe("success");
|
expect(outcome.status).toBe("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a request a person approved in a plugin go by RBAC", async () => {
|
||||||
|
register();
|
||||||
|
const outcome = await runOperation({
|
||||||
|
...base,
|
||||||
|
origin: { kind: "plugin", pluginId: "x", via: "approved" },
|
||||||
|
});
|
||||||
|
expect(outcome.status).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a community read as a change", async () => {
|
it("treats a community read as a change", async () => {
|
||||||
state.plugin.source = "user";
|
state.plugin.source = "user";
|
||||||
state.plugin.signedTier = "community";
|
state.plugin.signedTier = "community";
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
|||||||
|
|
||||||
/** What clicking a host does when it already has an open tab. */
|
/** What clicking a host does when it already has an open tab. */
|
||||||
export type HostClickBehavior =
|
export type HostClickBehavior =
|
||||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
|
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
|
||||||
|
|
||||||
export interface HostSidebarFilterState {
|
export interface HostSidebarFilterState {
|
||||||
status: ("online" | "offline" | "pinned")[];
|
status: ("online" | "offline" | "pinned")[];
|
||||||
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
|
|||||||
"newTab",
|
"newTab",
|
||||||
"focusExisting",
|
"focusExisting",
|
||||||
"focusExistingDoubleClickNew",
|
"focusExistingDoubleClickNew",
|
||||||
|
"hostPage",
|
||||||
];
|
];
|
||||||
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
||||||
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface ApiKey {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** Null for full access. */
|
||||||
|
scopes?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatedApiKey extends ApiKey {
|
export interface CreatedApiKey extends ApiKey {
|
||||||
@@ -97,12 +99,14 @@ export async function createApiKey(
|
|||||||
name: string,
|
name: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
expiresAt?: string,
|
expiresAt?: string,
|
||||||
|
scopes?: string[] | null,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/api-keys", {
|
const response = await authApi.post("/users/api-keys", {
|
||||||
name,
|
name,
|
||||||
userId,
|
userId,
|
||||||
expiresAt: expiresAt ?? null,
|
expiresAt: expiresAt ?? null,
|
||||||
|
scopes: scopes?.length ? scopes : null,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance {
|
|||||||
}) as AxiosInstance;
|
}) as AxiosInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An axios client rooted at core's /operations routes: the action catalog,
|
||||||
|
* runs, approvals, events, signals and cross host resources.
|
||||||
|
*/
|
||||||
|
export function createOperationsApi(): AxiosInstance {
|
||||||
|
return new Proxy(authApi, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
const value = Reflect.get(target, property, receiver);
|
||||||
|
if (typeof value !== "function") return value;
|
||||||
|
if (
|
||||||
|
property === "get" ||
|
||||||
|
property === "delete" ||
|
||||||
|
property === "post" ||
|
||||||
|
property === "put" ||
|
||||||
|
property === "patch"
|
||||||
|
) {
|
||||||
|
return (url: string, ...rest: unknown[]) =>
|
||||||
|
(value as (...args: unknown[]) => unknown).call(
|
||||||
|
target,
|
||||||
|
`/operations${url.startsWith("/") ? url : `/${url}`}`,
|
||||||
|
...rest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.bind(target);
|
||||||
|
},
|
||||||
|
}) as AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A raw fetch on this plugin's /plugin-api mount point, for a response the
|
* A raw fetch on this plugin's /plugin-api mount point, for a response the
|
||||||
* caller reads as a stream (server-sent events). axios buffers the whole
|
* caller reads as a stream (server-sent events). axios buffers the whole
|
||||||
|
|||||||
@@ -609,6 +609,7 @@
|
|||||||
"hostClickBehavior": "Clicking a host",
|
"hostClickBehavior": "Clicking a host",
|
||||||
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
|
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
|
||||||
"hostClickNewTab": "Always open a new tab",
|
"hostClickNewTab": "Always open a new tab",
|
||||||
|
"hostClickHostPage": "Open the host page, when a plugin offers one",
|
||||||
"hostClickFocusExisting": "Switch to the open tab",
|
"hostClickFocusExisting": "Switch to the open tab",
|
||||||
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
|
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
|
||||||
"openInNewTab": "Open in new tab",
|
"openInNewTab": "Open in new tab",
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
type KeybindingEditorProps,
|
type KeybindingEditorProps,
|
||||||
} from "@/shell/keybinding-registry";
|
} from "@/shell/keybinding-registry";
|
||||||
import { registerHostProtocol } from "@/sidebar/host-protocols";
|
import { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||||
|
import {
|
||||||
|
registerHostSection,
|
||||||
|
type HostSectionRenderProps,
|
||||||
|
} from "@/shell/host-sections";
|
||||||
import {
|
import {
|
||||||
registerDashboardCard,
|
registerDashboardCard,
|
||||||
type DashboardCardRenderProps,
|
type DashboardCardRenderProps,
|
||||||
@@ -424,6 +428,21 @@ export function createPluginApp(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerHostSection(section) {
|
||||||
|
return track(
|
||||||
|
registerHostSection({
|
||||||
|
id: section.id,
|
||||||
|
pluginId,
|
||||||
|
titleKey: key(section.titleKey),
|
||||||
|
order: section.order,
|
||||||
|
when: hostCallback(section.when ?? (() => true)) as never,
|
||||||
|
component: scoped(
|
||||||
|
section.component as unknown as ComponentType<HostSectionRenderProps>,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
registerHostContextMenuItem(item) {
|
registerHostContextMenuItem(item) {
|
||||||
return track(
|
return track(
|
||||||
registerHostContextMenuItem({
|
registerHostContextMenuItem({
|
||||||
|
|||||||
@@ -823,3 +823,13 @@ export {
|
|||||||
rightDockableIds,
|
rightDockableIds,
|
||||||
useRailItems,
|
useRailItems,
|
||||||
} from "@/sidebar/rail-items";
|
} from "@/sidebar/rail-items";
|
||||||
|
export {
|
||||||
|
hostSectionsFor,
|
||||||
|
type HostSectionDef,
|
||||||
|
listHostSections,
|
||||||
|
useHostSections,
|
||||||
|
} from "@/shell/host-sections";
|
||||||
|
export { HostTabEmbed } from "@/shell/HostTabEmbed";
|
||||||
|
import { createOperationsApi } from "@/lib/plugin-transport";
|
||||||
|
/** Core's /operations routes, with the user's own session. */
|
||||||
|
export const operationsApi = createOperationsApi();
|
||||||
@@ -471,6 +471,7 @@ export {
|
|||||||
resetHostContributions,
|
resetHostContributions,
|
||||||
} from "@/sidebar/host-contributions";
|
} from "@/sidebar/host-contributions";
|
||||||
export { registerHostProtocol } from "@/sidebar/host-protocols";
|
export { registerHostProtocol } from "@/sidebar/host-protocols";
|
||||||
|
export { registerHostSection, resetHostSections } from "@/shell/host-sections";
|
||||||
export {
|
export {
|
||||||
RAIL_ITEMS,
|
RAIL_ITEMS,
|
||||||
registerRailItem,
|
registerRailItem,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Host, Tab } from "@/types/ui-types";
|
||||||
|
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||||
|
import { renderTabContent } from "@/shell/tabUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a registered tab type inside another view, such as a host page, with
|
||||||
|
* the same props the tab stage gives it. The embed has its own instance id, so
|
||||||
|
* it never collides with the same tool opened as a normal tab.
|
||||||
|
*/
|
||||||
|
export function HostTabEmbed({
|
||||||
|
host,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
data,
|
||||||
|
isVisible = true,
|
||||||
|
shell,
|
||||||
|
}: {
|
||||||
|
host: Host;
|
||||||
|
type: string;
|
||||||
|
label?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
isVisible?: boolean;
|
||||||
|
shell: TabShellCallbacks;
|
||||||
|
}) {
|
||||||
|
const tab = useMemo<Tab>(
|
||||||
|
() => ({
|
||||||
|
id: `embed-${type}-${host.id}`,
|
||||||
|
instanceId: `embed-${type}-${host.id}`,
|
||||||
|
type,
|
||||||
|
label: label ?? host.name ?? host.ip,
|
||||||
|
host,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
// A new host or tool is a new tab; label and data changes keep it.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[type, host.id],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 flex flex-col">
|
||||||
|
{renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { Host } from "@/types/ui-types";
|
||||||
|
import { byOrderThenId, createRegistry } from "@/lib/registry";
|
||||||
|
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||||
|
|
||||||
|
export interface HostSectionRenderProps {
|
||||||
|
host: Host;
|
||||||
|
isVisible: boolean;
|
||||||
|
shell: TabShellCallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card a plugin adds to a host page. The page itself belongs to a plugin;
|
||||||
|
* core only keeps the list so any plugin can fill it.
|
||||||
|
*/
|
||||||
|
export interface HostSectionDef {
|
||||||
|
id: string;
|
||||||
|
pluginId?: string;
|
||||||
|
titleKey: string;
|
||||||
|
order?: number;
|
||||||
|
when: (host: Host) => boolean;
|
||||||
|
component: ComponentType<HostSectionRenderProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = createRegistry<HostSectionDef>(byOrderThenId);
|
||||||
|
|
||||||
|
export const registerHostSection = sections.register;
|
||||||
|
export const useHostSections = sections.useList;
|
||||||
|
export const listHostSections = sections.list;
|
||||||
|
export const resetHostSections = sections.reset;
|
||||||
|
|
||||||
|
export function hostSectionsFor(
|
||||||
|
all: HostSectionDef[],
|
||||||
|
host: Host,
|
||||||
|
): HostSectionDef[] {
|
||||||
|
return all.filter((section) => {
|
||||||
|
try {
|
||||||
|
return section.when(host);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user