Parcourir la source

feat: a node can register a credential type by name

Credential types were a fixed list of storage shapes - basic, bearer, api_key
and the rest - and nothing said that SD.cpp wants a basic credential whose
username is the sdcpp login. Someone creating one had to already know.

A node now declares what it wants:

  const credentialTypes = [{
      id: 'sdcpp', label: 'SD.cpp Server', baseType: 'basic',
      description: 'The username and password you sign in to sdcpp-restapi with'
  }];

Parsed from the node file like configSchema, so adding a type needs no rebuild -
the same reason nodes are JavaScript. The credentials page lists these first,
under "For a specific service", with the general shapes below.

A registered type is a NAME OVER a shape, never a new one. The C++ enum still
decides how a credential is encrypted and turned into an auth header; the name
is stored beside it as declaredType. That split matters here specifically: sdcpp
stores basic but authenticates as bearer, exchanging the username and password
for a token inside the node, so neither name alone is honest about it.

Compatibility runs both ways. A field asking for 'sdcpp' also accepts a plain
basic credential, and an sdcpp credential is accepted anywhere basic is, because
they hold identical fields - refusing either would only make an existing
credential unusable for no reason. The twelve SD.cpp nodes now ask for
['sdcpp', 'basic'], and the credential created before any of this still works
untouched.

A credential also shows by its registered name in node pickers - "SD.cpp Server"
rather than "basic".

Verified: the type reaches the API declared by 12 nodes; creating a credential
with declaredType sdcpp round-trips as type=basic declaredType=sdcpp; and the
pre-existing plain basic credential still matches the sdcpp filter. Full suite
57/57.
fszontagh il y a 1 mois
Parent
commit
f6f5e5b07f

+ 1 - 0
lib/credentials/credential_store.cpp

@@ -202,6 +202,7 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
     doc.metadata.name = request.name;
     doc.metadata.description = request.description;
     doc.metadata.type = request.type;
+    doc.metadata.declared_type = request.declared_type;
     doc.metadata.created_by = user_id;
     doc.metadata.allowed_workflows = request.allowed_workflows;
     doc.metadata.public_data = public_data;

+ 13 - 0
lib/credentials/credential_types.cpp

@@ -272,6 +272,9 @@ nlohmann::json CredentialMetadata::toJson() const {
     if (!public_data.is_null() && !public_data.empty()) {
         j["publicData"] = public_data;
     }
+    if (!declared_type.empty()) {
+        j["declaredType"] = declared_type;
+    }
     return j;
 }
 
@@ -283,6 +286,7 @@ CredentialMetadata CredentialMetadata::fromJson(const nlohmann::json& j) {
     meta.description = j.value("description", "");
     meta.type = credentialTypeFromString(j.value("type", "basic"));
     meta.created_by = j.value("createdBy", j.value("created_by", ""));
+    meta.declared_type = j.value("declaredType", "");
     meta.created_at = j.value("_createdAt", j.value("createdAt", j.value("created_at", int64_t{0})));
     meta.updated_at = j.value("_updatedAt", j.value("updatedAt", j.value("updated_at", int64_t{0})));
     if (j.contains("allowedWorkflows") && j["allowedWorkflows"].is_array()) {
@@ -329,6 +333,9 @@ nlohmann::json CredentialInfo::toJson() const {
     if (!public_data.is_null() && !public_data.empty()) {
         j["publicData"] = public_data;
     }
+    if (!declared_type.empty()) {
+        j["declaredType"] = declared_type;
+    }
     return j;
 }
 
@@ -343,6 +350,7 @@ CredentialInfo CredentialInfo::fromMetadata(const CredentialMetadata& metadata)
     info.updated_at = metadata.updated_at;
     info.allowed_workflows = metadata.allowed_workflows;
     info.public_data = metadata.public_data;
+    info.declared_type = metadata.declared_type;
     return info;
 }
 
@@ -358,6 +366,11 @@ Result<CreateCredentialRequest> CreateCredentialRequest::fromJson(const nlohmann
     if (!j.contains("type") || !j["type"].is_string()) {
         return Error(ErrorCode::InvalidArgument, "Credential type is required");
     }
+    // A named type a node registered, such as "sdcpp". It rides alongside the
+    // storage type rather than replacing it: the store still needs to know it
+    // is a basic credential to encrypt it and build an auth header.
+    req.declared_type = j.value("declaredType", "");
+
     try {
         req.type = credentialTypeFromString(j["type"].get<std::string>());
     } catch (const std::exception& e) {

+ 8 - 0
lib/credentials/credential_types.hpp

@@ -147,6 +147,12 @@ struct CredentialMetadata {
     int64_t updated_at = 0;
     std::vector<std::string> allowed_workflows;  // Empty = all workflows
     nlohmann::json public_data;  // Non-secret fields for editing (e.g., host, username)
+    // The named type this credential was created as, when a node registered one -
+    // "sdcpp" rather than the bare "basic" it is stored as. Purely a label: the
+    // type above still decides how it is encrypted and turned into an auth
+    // header. It exists so a person can tell which of four basic credentials is
+    // the one an SD.cpp node wants.
+    std::string declared_type;
 
     nlohmann::json toJson() const;
     static CredentialMetadata fromJson(const nlohmann::json& j);
@@ -172,6 +178,7 @@ struct CredentialInfo {
     int64_t updated_at = 0;
     std::vector<std::string> allowed_workflows;
     nlohmann::json public_data;  // Non-secret fields for editing
+    std::string declared_type;   // See CredentialMetadata::declared_type
 
     nlohmann::json toJson() const;
     static CredentialInfo fromMetadata(const CredentialMetadata& metadata);
@@ -182,6 +189,7 @@ struct CreateCredentialRequest {
     std::string name;
     std::string description;
     CredentialType type;
+    std::string declared_type;  // Optional named type a node registered
     nlohmann::json data;  // Type-specific data (Basic, Bearer, ApiKey, OAuth2)
     std::vector<std::string> allowed_workflows;
 

+ 17 - 1
nodes/sdcpp/sdcpp-edit.js

@@ -7,6 +7,22 @@
  * @icon wand-2
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         prompt: {
             type: 'string', title: 'Prompt',

+ 16 - 0
nodes/sdcpp/sdcpp-fetch-output.js

@@ -7,6 +7,22 @@
  * @icon download
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {

+ 17 - 1
nodes/sdcpp/sdcpp-img2img.js

@@ -7,6 +7,22 @@
  * @icon images
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         prompt: {
             type: 'string', title: 'Prompt',

+ 16 - 0
nodes/sdcpp/sdcpp-job-status.js

@@ -7,6 +7,22 @@
  * @icon activity
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {

+ 16 - 0
nodes/sdcpp/sdcpp-job-wait.js

@@ -7,6 +7,22 @@
  * @icon hourglass
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {

+ 17 - 1
nodes/sdcpp/sdcpp-model-load.js

@@ -7,6 +7,22 @@
  * @icon box
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         modelName: {
             type: 'string', title: 'Model',

+ 17 - 1
nodes/sdcpp/sdcpp-model.js

@@ -7,6 +7,22 @@
  * @icon layers
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password. Listing models needs one',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         modelType: {
             type: 'string', title: 'Kind',

+ 17 - 1
nodes/sdcpp/sdcpp-txt2img.js

@@ -7,6 +7,22 @@
  * @icon image
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         prompt: {
             type: 'string', title: 'Prompt',

+ 17 - 1
nodes/sdcpp/sdcpp-txt2vid.js

@@ -7,6 +7,22 @@
  * @icon clapperboard
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         prompt: {
             type: 'string', title: 'Prompt',

+ 17 - 1
nodes/sdcpp/sdcpp-unload.js

@@ -7,6 +7,22 @@
  * @icon eraser
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         target: {
             type: 'string', title: 'Unload',

+ 17 - 1
nodes/sdcpp/sdcpp-upscale.js

@@ -7,6 +7,22 @@
  * @icon maximize-2
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         imageBase64: {
             type: 'string', title: 'Image (base64)',

+ 17 - 1
nodes/sdcpp/sdcpp-upscaler-load.js

@@ -7,6 +7,22 @@
  * @icon maximize
  */
 
+// The credential this node wants, named so it can be found. It is stored as a
+// plain basic credential - that is what decides how it is encrypted - and this
+// only says which basic credential is the SD.cpp one. Anything that accepts a
+// basic credential still accepts this, and this node still accepts a plain
+// basic credential, because the shape is identical.
+const credentialTypes = [
+    {
+        id: 'sdcpp',
+        label: 'SD.cpp Server',
+        baseType: 'basic',
+        description: 'The username and password you sign in to sdcpp-restapi with. The node exchanges them for a token before every call',
+        usernameLabel: 'Username',
+        passwordLabel: 'Password'
+    }
+];
+
 const configSchema = {
     type: 'object',
     properties: {
@@ -18,7 +34,7 @@ const configSchema = {
         credentialId: {
             type: 'string', title: 'Credential',
             description: 'A basic credential holding the sdcpp-restapi username and password',
-            dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } }
+            dynamicOptions: { source: 'credentials', filter: { type: ['sdcpp', 'basic'] } }
         },
         modelName: {
             type: 'string', title: 'Upscaler',

+ 3 - 0
src/webserver/api/node_controller.cpp

@@ -206,6 +206,9 @@ void NodeController::listNodes(const httplib::Request& req, httplib::Response& r
         if (!node.output_schema.is_null()) {
             n["outputSchema"] = node.output_schema;
         }
+        if (!node.credential_types.is_null() && !node.credential_types.empty()) {
+            n["credentialTypes"] = node.credential_types;
+        }
 
         nlohmann::json inputs = nlohmann::json::array();
         for (const auto& input : node.inputs) {

+ 25 - 0
src/webserver/nodes/node_store.cpp

@@ -198,6 +198,9 @@ nlohmann::json StoredNode::toJson() const {
     j["isTrigger"] = is_trigger;
     j["isScheduled"] = is_scheduled;
     j["configSchema"] = config_schema;
+    if (!credential_types.is_null() && !credential_types.empty()) {
+        j["credentialTypes"] = credential_types;
+    }
     j["inputSchema"] = input_schema;
     j["outputSchema"] = output_schema;
     j["ownerId"] = owner_id;
@@ -238,6 +241,9 @@ StoredNode StoredNode::fromJson(const nlohmann::json& j) {
     if (j.contains("configSchema")) {
         node.config_schema = j["configSchema"];
     }
+    if (j.contains("credentialTypes")) {
+        node.credential_types = j["credentialTypes"];
+    }
     if (j.contains("inputSchema")) {
         node.input_schema = j["inputSchema"];
     }
@@ -306,6 +312,25 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
         node.is_scheduled = true;
     }
 
+    // Parse credentialTypes - a node naming the credential it wants, so the
+    // credentials page can offer it by name instead of leaving someone to guess
+    // that SD.cpp means "basic".
+    std::regex cred_types_regex(R"(const\s+credentialTypes\s*=\s*)");
+    if (std::regex_search(code, match, cred_types_regex)) {
+        size_t start_pos = match.position() + match.length();
+        size_t bracket_pos = code.find('[', start_pos);
+        if (bracket_pos != std::string::npos) {
+            std::string jsonStr = jsLiteralToJson(code, bracket_pos);
+            if (!jsonStr.empty()) {
+                try {
+                    node.credential_types = nlohmann::json::parse(jsonStr);
+                } catch (const nlohmann::json::exception& e) {
+                    LOG_WARN("Failed to parse credentialTypes for {}: {}", node.id, e.what());
+                }
+            }
+        }
+    }
+
     // Parse configSchema
     std::regex config_start_regex(R"(const\s+configSchema\s*=\s*)");
     if (std::regex_search(code, match, config_start_regex)) {

+ 3 - 0
src/webserver/nodes/node_store.hpp

@@ -31,6 +31,9 @@ struct StoredNode {
     std::string icon;
     std::string code;                  // Full JavaScript source
     nlohmann::json config_schema;
+    // Credential types this node registers, so the credentials page can offer
+    // them by name. A label over an existing storage shape, not a new one.
+    nlohmann::json credential_types;
     nlohmann::json input_schema;
     nlohmann::json output_schema;
     std::vector<NodeIO> inputs;

+ 7 - 0
webui/src/api/credentials.ts

@@ -9,6 +9,9 @@ export interface CredentialInfo {
   type: CredentialType
   createdBy: string
   createdAt: number
+  // The named type this was created as, when a node registered one. The type
+  // above is still what decides how it is stored and used.
+  declaredType?: string
   updatedAt: number
   allowedWorkflows: string[]
   publicData?: Record<string, unknown>  // Non-secret fields for editing
@@ -67,6 +70,9 @@ export interface PostgresqlData {
 }
 
 export interface CreateCredentialRequest {
+  // A node-registered type such as "sdcpp". Optional: the built-in type below
+  // is still what the store uses.
+  declaredType?: string
   name: string
   description?: string
   type: CredentialType
@@ -90,6 +96,7 @@ function transformCredential(data: any): CredentialInfo {
     type: data.type,
     createdBy: data.createdBy || data.ownerId,
     createdAt: data.createdAt || data._createdAt,
+    declaredType: data.declaredType || undefined,
     updatedAt: data.updatedAt || data._updatedAt,
     allowedWorkflows: data.allowedWorkflows || [],
     publicData: data.publicData,

+ 20 - 4
webui/src/components/workflow/NodeConfigModal.tsx

@@ -74,6 +74,15 @@ export function NodeConfigModal({
 
   const credentials: CredentialInfo[] = credentialsData?.credentials || []
 
+  // Names for the credential types nodes registered, so a credential shows as
+  // "SD.cpp Server" rather than the "basic" it is stored as.
+  const credentialTypeLabels: Record<string, string> = {}
+  for (const def of nodeDefs) {
+    for (const ct of ((def as any).credentialTypes || [])) {
+      if (ct?.id && ct?.label) credentialTypeLabels[ct.id] = ct.label
+    }
+  }
+
   // Only fetched when the node being edited actually offers a workflow field.
   const needsWorkflowList = Object.values(
     ((nodeDefs.find((nd) => nd.id === selectedNodeData?.type)?.configSchema as any)?.properties) || {}
@@ -571,15 +580,22 @@ export function NodeConfigModal({
                             // Filter by credential type if specified. A node that
                             // works with more than one type lists them all, so an
                             // account stored once can serve both.
+                            // A filter naming a registered type also accepts
+                            // the plain credential it is stored as, and the
+                            // other way round: "sdcpp" and "basic" hold exactly
+                            // the same fields, so refusing one would only make
+                            // an existing credential unusable for no reason.
                             const typeFilter = prop.dynamicOptions?.filter?.type
                             if (!typeFilter) return true
-                            return Array.isArray(typeFilter)
-                              ? typeFilter.includes(cred.type)
-                              : cred.type === typeFilter
+                            const wanted = Array.isArray(typeFilter) ? typeFilter : [typeFilter]
+                            return (
+                              wanted.includes(cred.type) ||
+                              (!!cred.declaredType && wanted.includes(cred.declaredType))
+                            )
                           })
                           .map((cred) => (
                             <option key={cred.id} value={cred.id}>
-                              {cred.name} ({cred.type})
+                              {cred.name} ({credentialTypeLabels[cred.declaredType || cred.type] || cred.type})
                             </option>
                           ))}
                       </select>

+ 90 - 2
webui/src/pages/CredentialsPage.tsx

@@ -1,5 +1,6 @@
 import { useState } from 'react'
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { nodesApi } from '../api/workflows'
 import { format } from 'date-fns'
 
 // Helper to safely format timestamps that might be invalid or too large
@@ -41,6 +42,17 @@ import {
   UpdateCredentialRequest,
 } from '../api/credentials'
 
+// A credential type a node registered: a name over one of the built-in shapes,
+// so someone creating a credential can pick "SD.cpp Server" instead of guessing
+// that it means "basic".
+interface RegisteredType {
+  id: string
+  label: string
+  baseType: CredentialType
+  description?: string
+  registeredBy: string[]
+}
+
 const CREDENTIAL_TYPE_INFO: Record<
   CredentialType,
   { label: string; icon: React.FC<{ className?: string }>; description: string }
@@ -344,6 +356,27 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
   const [name, setName] = useState(credential?.name || '')
   const [description, setDescription] = useState(credential?.description || '')
   const [type, setType] = useState<CredentialType>(credential?.type || 'basic')
+  // A node-registered type such as "sdcpp". It rides alongside the built-in
+  // type rather than replacing it - the shape is what gets stored.
+  const [declaredType, setDeclaredType] = useState<string>(credential?.declaredType || '')
+
+  const { data: nodesForTypes } = useQuery({
+    queryKey: ['node-registered-credential-types'],
+    queryFn: () => nodesApi.list(),
+    staleTime: 5 * 60 * 1000,
+  })
+  const registeredTypes: RegisteredType[] = (() => {
+    const found = new Map<string, RegisteredType>()
+    for (const def of (nodesForTypes?.nodes || [])) {
+      for (const ct of ((def as any).credentialTypes || [])) {
+        if (!ct?.id || !ct?.baseType) continue
+        const existing = found.get(ct.id)
+        if (existing) existing.registeredBy.push(def.name)
+        else found.set(ct.id, { ...ct, registeredBy: [def.name] })
+      }
+    }
+    return Array.from(found.values())
+  })()
   const [showSecrets, setShowSecrets] = useState(false)
   const [error, setError] = useState<string | null>(null)
 
@@ -500,6 +533,7 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
       createMutation.mutate({
         name: name.trim(),
         description: description.trim() || undefined,
+        declaredType: declaredType || undefined,
         type,
         data,
       })
@@ -569,6 +603,57 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
                 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
                   Type *
                 </label>
+                {/* Types a node asked for, listed first. Someone creating a
+                    credential is usually here because a node told them to, and
+                    "SD.cpp Server" is findable in a way that "Basic Auth" is
+                    not. Choosing one still stores the shape it is built on. */}
+                {registeredTypes.length > 0 && (
+                  <div className="mb-3">
+                    <div className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-1.5">
+                      For a specific service
+                    </div>
+                    <div className="grid grid-cols-2 gap-2">
+                      {registeredTypes.map((rt) => (
+                        <button
+                          key={rt.id}
+                          type="button"
+                          onClick={() => {
+                            setDeclaredType(rt.id)
+                            setType(rt.baseType)
+                          }}
+                          className={`flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-colors ${
+                            declaredType === rt.id
+                              ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30'
+                              : 'border-gray-200 dark:border-slate-600 hover:border-gray-300 dark:hover:border-slate-500'
+                          }`}
+                        >
+                          <KeyRound
+                            className={`w-5 h-5 ${
+                              declaredType === rt.id ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400'
+                            }`}
+                          />
+                          <div>
+                            <div
+                              className={`font-medium text-sm ${
+                                declaredType === rt.id
+                                  ? 'text-primary-700 dark:text-primary-300'
+                                  : 'text-gray-900 dark:text-gray-100'
+                              }`}
+                            >
+                              {rt.label}
+                            </div>
+                            <div className="text-xs text-gray-500 dark:text-gray-400">
+                              {rt.description || `Stored as ${rt.baseType}`}
+                            </div>
+                          </div>
+                        </button>
+                      ))}
+                    </div>
+                    <div className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mt-3 mb-1.5">
+                      Or a general one
+                    </div>
+                  </div>
+                )}
                 <div className="grid grid-cols-2 gap-2">
                   {(Object.keys(CREDENTIAL_TYPE_INFO) as CredentialType[]).map((t) => {
                     const info = CREDENTIAL_TYPE_INFO[t]
@@ -577,9 +662,12 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
                       <button
                         key={t}
                         type="button"
-                        onClick={() => setType(t)}
+                        onClick={() => {
+                          setType(t)
+                          setDeclaredType('')
+                        }}
                         className={`flex items-center gap-3 p-3 rounded-lg border-2 text-left transition-colors ${
-                          type === t
+                          type === t && !declaredType
                             ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30'
                             : 'border-gray-200 dark:border-slate-600 hover:border-gray-300 dark:hover:border-slate-500'
                         }`}