mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move termix identity into a plugin (C6)
This commit is contained in:
1 parent
6e56c90aac
commit
4dbce08379
157 files changed
+20743
-5766
No files matched your search
@@ -441,29 +441,30 @@ when the plugin deactivates. Every registered component is wrapped in the
|
||||
plugin's scope (so the hooks know which plugin they belong to), an error
|
||||
boundary and Suspense.
|
||||
|
||||
| Member | What it does |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
|
||||
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
|
||||
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
|
||||
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
|
||||
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
|
||||
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
|
||||
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
|
||||
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
|
||||
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
|
||||
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
| Member | What it does |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `registerRailItem` | A rail button, hidden from users without its `permission`. Hideable items show in Appearance > Sidebar > Navigation. The id must be a declared view |
|
||||
| `registerPanel` | A rail panel, id in `contributes.panels` or `contributes.tabs` |
|
||||
| `registerTab` | A tab type, id in `contributes.tabs`. Options cover persistence, layouts, singletons and host needs |
|
||||
| `registerHostEditorSection` | A host editor tab in the top strip or the SSH group, with `form`, `setField` and `updateForm` |
|
||||
| `registerHostAction` | A connect or open action on a host: sidebar row, palette, dashboard, default connect |
|
||||
| `registerHostProtocol` | A connection protocol next to SSH (**B14**): General tab switch, Hosts panel filter and grouping, Quick Connect, the host's primary type and port |
|
||||
| `registerHostBadge`, `registerHostContextMenuItem` | Host row badges and context menu entries |
|
||||
| `registerPaletteEntry` | A global or per-host command palette entry |
|
||||
| `registerDashboardCard` | A dashboard card, id in `contributes.dashboardCards` |
|
||||
| `registerHomepageWidget` | A homepage widget, with an optional edit form |
|
||||
| `registerSettingsComponent` | A component a `type: "custom"` settings field names |
|
||||
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
|
||||
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
|
||||
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
|
||||
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
|
||||
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
|
||||
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
|
||||
| `t`, `hasPermission` | The plugin's strings and a permission check, for code outside a component (a toast from `activate`) |
|
||||
| `tabs.open`, `getLayout`, `applyLayout`, `onChange` | Tab control, used by workspaces; `tabs.openRailView` (**B12**) opens a rail view |
|
||||
| `guest`, `info`, `onDispose` | Guest mode flag, plugin info, extra cleanup |
|
||||
| `desktop` | `available`, `remoteServerUrl()` and `onRemoteServerChange`: whether the frontend runs in the desktop app and which remote server it syncs with (**C6**) |
|
||||
|
||||
Hooks: `useTranslation` (the plugin's namespace), `usePermission` (a short
|
||||
name resolves to `<id>.<name>`), `useSettings`, `useHost`, `useHosts`,
|
||||
@@ -484,8 +485,11 @@ components `send(type, data)` for the session socket and `connectPayload()`,
|
||||
the data an `<interaction>_auth_completed` needs to reconnect, so a sign-in
|
||||
flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`,
|
||||
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
|
||||
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and
|
||||
`dashboard.secondaryView`. **B16** added the middle two, component slots in
|
||||
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics`,
|
||||
`credentials.badges` and `dashboard.secondaryView`. **C6** added
|
||||
`credentials.badges`, a component slot after the key/password badge on each
|
||||
saved credential row (`{ credentialId }`); termix-identity shows its "ID"
|
||||
badge there, so the credential list no longer fetches Termix ID data. **B16** added the middle two, component slots in
|
||||
the dashboard's host status card (`{ hostId, online }`, one per host row) and
|
||||
the homepage's host status widget (`{ hostId, shownMetrics, online }`): core
|
||||
draws the status and a plugin draws the numbers, so core no longer calls the
|
||||
@@ -1190,6 +1194,32 @@ base types and branches on no plugin type name.
|
||||
message names (`vault_auth_url`, `vault_completed`, `vault_error`) for
|
||||
Termix-Mobile.
|
||||
|
||||
**C6** moved Termix Identity out. It never was an SSH auth type: issued
|
||||
certificates are downloaded once and never stored, and no connect path reads
|
||||
them, so `termix-identity` is a standalone feature plugin with no provider.
|
||||
|
||||
- It adopts `termix_identities`, `termix_identity_keys` and
|
||||
`termix_identity_ca` as `p_termix_identity_identities`, `_keys` and `_ca`.
|
||||
The migrations keep, by hand, the links from keys and CA to their identity
|
||||
(cascade) and from a key to `ssh_credentials` (set null).
|
||||
- The CA private key is sealed with `ctx.secrets.seal` (`secrets:own`), so
|
||||
issuing a certificate no longer needs the owner's data key unlocked.
|
||||
`termix-identity-ca-migration.ts` reseals 2.8 rows, which core encrypted
|
||||
with the owner's data key; it runs from `runPluginDataMoves` right after
|
||||
the adoption and again at boot for a row whose key was not open.
|
||||
- The resolver, `/plugin-api/termix-identity/u/<handle>[/<ALGO>|/ca]`, is
|
||||
public through `ctx.http.router({ public })`. The 2.8 URLs under
|
||||
`/termix-id/u/` answer 308 to it (`registerTermixIdCompatRoutes` in
|
||||
`host-compat-routes.ts`; nginx proxies only `/termix-id/u/` now), because
|
||||
servers fetch them from provisioning scripts. The management routes moved
|
||||
and are gated on the new `termix-identity.use` (admins and users).
|
||||
- Publishing a saved key and saving a generated pair go through
|
||||
`ctx.credentials.listSshKeys` and `createSshKey` (**C6**, see the ctx
|
||||
surface): the plugin never sees a stored private key.
|
||||
- The rail item keeps its id `termix-id`, so saved layouts, hidden-rail
|
||||
preferences and presets carry over, and uses `app.desktop` to stay hidden
|
||||
in a standalone desktop app, as the shell did before.
|
||||
|
||||
#### One SSH connect pipeline
|
||||
|
||||
Every SSH connection, core's and plugins', goes through
|
||||
@@ -1693,6 +1723,7 @@ deciding, not the mechanism.
|
||||
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
|
||||
| `hosts:write` | medium | Create and change hosts |
|
||||
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials for the acting user |
|
||||
| `network:outbound` | medium | Make outbound requests |
|
||||
| `network:serve` | medium | Open a listening port |
|
||||
| `network:broadcast` | medium | Send network broadcast packets |
|
||||
@@ -1763,6 +1794,8 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
|
||||
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
|
||||
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
|
||||
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
|
||||
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
||||
@@ -1924,6 +1957,21 @@ resolution (the owner's shared snapshot or their own override), never the
|
||||
owner's raw secret; no connect access, or no host, is `null`. Every call is
|
||||
audited as `plugin_credentials_read`, allowed or refused.
|
||||
|
||||
**C6** added two members for Termix Identity, both for the acting user's own
|
||||
saved SSH keys and both audited:
|
||||
|
||||
- `listSshKeys()` returns `{ id, name, username, publicKey }` for every key
|
||||
credential the actor owns. Core derives the public key from the private
|
||||
key when none was stored, so the plugin never receives private material.
|
||||
Needs `credentials:use` and the core `credentials.view` permission.
|
||||
- `createSshKey({ name, description, username, privateKey, publicKey,
|
||||
keyType })` saves a key pair as a new encrypted credential and returns its
|
||||
id. Needs `credentials:write`, a new capability, the core
|
||||
`credentials.create` permission and an unlocked data key. There is no
|
||||
delete: a plugin that has to undo a save orders its own writes so it never
|
||||
needs one (termix-identity publishes the key first and removes that row if
|
||||
the save fails).
|
||||
|
||||
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
|
||||
|
||||
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
|
||||
@@ -2570,7 +2618,7 @@ Then, with the app running:
|
||||
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
|
||||
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
|
||||
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
|
||||
opkssh and warpgate (C3), step-ca (C4), vault (C5),
|
||||
opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6),
|
||||
which import nothing from core. The others still reach core by relative
|
||||
path (`../../../../src/backend/...`), which an esbuild plugin,
|
||||
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
|
||||
|
||||
@@ -657,3 +657,17 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
editor (sharing only offered with "Share Vault profiles"); switch to the new
|
||||
redirect URI after allowing it in the Vault role; disable the vault plugin
|
||||
and a Vault host says it needs it, then enable it again.
|
||||
- **C6 (termix-identity):** only the public resolver URLs under
|
||||
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
|
||||
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
|
||||
Nothing in this repo calls them; confirm Termix-Mobile does not before
|
||||
2.9.0 ships, and add redirects if it does. Owner: D0.
|
||||
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
|
||||
no `idx_termix_identity_keys_identity` (core never created one there and
|
||||
an adoption writes no MySQL indexes), so the public resolver scans the
|
||||
keys table. Add it in a later plugin migration if a MySQL install needs
|
||||
it. Owner: D0.
|
||||
- **C6 (termix-identity):** the docs site still shows the
|
||||
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
|
||||
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
|
||||
D0.
|
||||
@@ -106,8 +106,10 @@
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
"credentials:use",
|
||||
"credentials:write",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"network:broadcast",
|
||||
"users:read",
|
||||
"events:core",
|
||||
"notify:send",
|
||||
@@ -116,6 +118,7 @@
|
||||
"hosts:read",
|
||||
"db:own",
|
||||
"kv:own",
|
||||
"files:own",
|
||||
"secrets:own",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
|
||||
@@ -1382,11 +1382,42 @@ export interface PluginProtocolTarget {
|
||||
};
|
||||
}
|
||||
|
||||
/** One of the acting user's saved SSH key credentials, public half only. */
|
||||
export interface PluginSshKeyCredential {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string | null;
|
||||
/** "<type> <base64>", derived from the private key when none was stored. Null when it cannot be. */
|
||||
publicKey: string | null;
|
||||
}
|
||||
|
||||
/** A key pair to save as a new credential for the acting user. */
|
||||
export interface PluginSshKeyCredentialInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
username?: string | null;
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
/** The key's algorithm token, such as "ssh-ed25519". */
|
||||
keyType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plaintext host credentials, for a plugin that has to hand them to another
|
||||
* program. Needs credentials:read, and every call is audited.
|
||||
*/
|
||||
export interface PluginCredentials {
|
||||
/**
|
||||
* The acting user's own SSH key credentials, without any private material.
|
||||
* Needs credentials:use and the core credentials.view permission. Audited.
|
||||
*/
|
||||
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
|
||||
/**
|
||||
* Saves a key pair as a new encrypted credential owned by the acting user
|
||||
* and returns its id. Needs credentials:write, the core credentials.create
|
||||
* permission and an unlocked data key. Audited.
|
||||
*/
|
||||
createSshKey: (input: PluginSshKeyCredentialInput) => Promise<{ id: number }>;
|
||||
/** Null when the host does not exist or the acting user cannot connect to it. */
|
||||
resolveHostProtocol: (
|
||||
hostId: number,
|
||||
@@ -1627,7 +1658,7 @@ export interface PluginContext {
|
||||
readonly auth: PluginAuth;
|
||||
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
|
||||
readonly desktop: PluginDesktop;
|
||||
/** Plaintext host protocol credentials. Needs credentials:read. */
|
||||
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
|
||||
readonly credentials: PluginCredentials;
|
||||
/** Audit lines under the plugin's own action names. */
|
||||
readonly audit: PluginAudit;
|
||||
|
||||
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("credentials:use", "medium"),
|
||||
entry("credentials:write", "medium"),
|
||||
entry("network:outbound", "medium"),
|
||||
entry("network:serve", "medium"),
|
||||
entry("network:broadcast", "medium"),
|
||||
|
||||
@@ -530,6 +530,19 @@ export interface PluginWsTarget {
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
/** Where the frontend runs, for a plugin that behaves differently in the desktop app. */
|
||||
export interface DesktopApi {
|
||||
/** True inside the Termix desktop app. */
|
||||
readonly available: boolean;
|
||||
/**
|
||||
* The remote server the desktop app is connected to for sync, or null
|
||||
* outside the desktop app and while it runs standalone.
|
||||
*/
|
||||
remoteServerUrl: () => Promise<string | null>;
|
||||
/** Called when the desktop app connects to or leaves a remote server. */
|
||||
onRemoteServerChange: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
export interface TermixAppInfo {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -629,6 +642,8 @@ export interface TermixApp extends TermixAppInfo {
|
||||
options?: { origin?: unknown },
|
||||
) => Promise<PluginWsTarget | null>;
|
||||
tabs: TabsApi;
|
||||
/** The desktop app, when the frontend runs in it. */
|
||||
desktop: DesktopApi;
|
||||
/** Registered cleanup, run when the plugin is disabled. */
|
||||
onDispose: (dispose: Disposer) => void;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ import type {
|
||||
PluginHostShareResult,
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
@@ -120,6 +122,8 @@ export interface FakeContextOptions {
|
||||
* "<hostId>:<protocol>". A missing key answers null.
|
||||
*/
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys answers. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||
@@ -273,6 +277,8 @@ export interface FakePluginContext {
|
||||
nativeRdpLaunches: PluginNativeRdpRequest[];
|
||||
/** Every ctx.credentials.resolveHostProtocol call, in order. */
|
||||
credentialReads: Array<{ hostId: number; protocol: string }>;
|
||||
/** Every ctx.credentials.createSshKey call, with the id it answered. */
|
||||
createdSshKeys: Array<PluginSshKeyCredentialInput & { id: number }>;
|
||||
/** Every ctx.notify.send call, with the actor it ran as. */
|
||||
notifications: Array<{
|
||||
actor: string | undefined;
|
||||
@@ -392,6 +398,7 @@ export function createFakeContext(
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const nativeRdpLaunches: FakePluginContext["nativeRdpLaunches"] = [];
|
||||
const credentialReads: FakePluginContext["credentialReads"] = [];
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
@@ -972,6 +979,25 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
listSshKeys: async () => [
|
||||
...(options.sshKeyCredentials ?? []),
|
||||
...createdSshKeys.map((key) => ({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
username: key.username ?? null,
|
||||
publicKey: key.publicKey,
|
||||
})),
|
||||
],
|
||||
createSshKey: async (input) => {
|
||||
const id =
|
||||
Math.max(
|
||||
0,
|
||||
...(options.sshKeyCredentials ?? []).map((key) => key.id),
|
||||
...createdSshKeys.map((key) => key.id),
|
||||
) + 1;
|
||||
createdSshKeys.push({ ...input, id });
|
||||
return { id };
|
||||
},
|
||||
resolveHostProtocol: async (hostId, protocol) => {
|
||||
credentialReads.push({ hostId, protocol });
|
||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||
@@ -1049,6 +1075,7 @@ export function createFakeContext(
|
||||
desktopWindows,
|
||||
nativeRdpLaunches,
|
||||
credentialReads,
|
||||
createdSshKeys,
|
||||
notifications,
|
||||
fetches,
|
||||
processRuns,
|
||||
@@ -1105,6 +1132,8 @@ export interface MockContextOptions {
|
||||
sshHosts?: PluginSshHost[];
|
||||
/** What ctx.credentials serves. See FakeContextOptions. */
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys serves. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||
@@ -1164,6 +1193,7 @@ export function createMockCtx(
|
||||
sshHosts: options.sshHosts,
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
sshKeyCredentials: options.sshKeyCredentials,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
hostStatuses: options.hostStatuses,
|
||||
notificationChannels: options.notificationChannels,
|
||||
@@ -1462,6 +1492,14 @@ export function createMockCtx(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
listSshKeys: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.listSshKeys();
|
||||
},
|
||||
createSshKey: async (input) => {
|
||||
require("credentials:write");
|
||||
return ctx.credentials.createSshKey(input);
|
||||
},
|
||||
resolveHostProtocol: async (hostId, protocol) => {
|
||||
require("credentials:read");
|
||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||
|
||||
Reference in new issue
Block a user