From d42b155c1a803b0f090532fef3787feaae2d6e92 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:01:01 +0800 Subject: [PATCH 01/26] feat: external trigger slug rules and payload contract validator --- .../ExternalTriggerPayloadValidator.cs | 104 ++++++++++++++++++ .../ExternalTriggers/ExternalTriggerSlug.cs | 17 +++ .../ExternalTriggerPayloadValidatorTests.cs | 95 ++++++++++++++++ .../Workflows/ExternalTriggerSlugTests.cs | 31 ++++++ 4 files changed, 247 insertions(+) create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs new file mode 100644 index 000000000..c701f4b92 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -0,0 +1,104 @@ +using System.Text.Json; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Validates external request bodies strictly against the supported input-schema keywords. The LLM validator is +/// deliberately lenient about additional properties, so using it here would silently weaken a public API contract. +/// +public static class ExternalTriggerPayloadValidator +{ + public static IReadOnlyList Validate(JsonElement body, JsonElement inputSchema) + { + var errors = new List(); + ValidateNode(body, inputSchema, "$", errors); + return errors; + } + + private static void ValidateNode(JsonElement value, JsonElement schema, string path, List errors) + { + if (schema.ValueKind != JsonValueKind.Object) return; + + if (schema.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String && + !TypeMatches(value, type.GetString()!)) + { + errors.Add($"{path}: expected type '{type.GetString()}' but got {KindName(value.ValueKind)}"); + return; + } + + if (schema.TryGetProperty("enum", out var enumValues) && enumValues.ValueKind == JsonValueKind.Array && + !enumValues.EnumerateArray().Any(candidate => JsonValueEquals(value, candidate))) + errors.Add($"{path}: value '{value}' is not one of the allowed enum values"); + + if (value.ValueKind == JsonValueKind.Object) + { + ValidateObject(value, schema, path, errors); + } + else if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out var items)) + { + var index = 0; + foreach (var item in value.EnumerateArray()) + ValidateNode(item, items, $"{path}[{index++}]", errors); + } + else if (value.ValueKind == JsonValueKind.String && schema.TryGetProperty("maxLength", out var maxLength) && + maxLength.TryGetInt32(out var maximum) && value.GetString()!.Length > maximum) + { + errors.Add($"{path}: string exceeds maximum length {maximum}"); + } + } + + private static void ValidateObject(JsonElement value, JsonElement schema, string path, List errors) + { + if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) + { + foreach (var property in required.EnumerateArray()) + { + if (property.ValueKind == JsonValueKind.String && !value.TryGetProperty(property.GetString()!, out _)) + errors.Add($"{path}.{property.GetString()}: required property is missing"); + } + } + + var hasProperties = schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object; + var closesObject = schema.TryGetProperty("additionalProperties", out var additionalProperties) && + additionalProperties.ValueKind == JsonValueKind.False; + + foreach (var property in value.EnumerateObject()) + { + if (hasProperties && properties.TryGetProperty(property.Name, out var propertySchema)) + ValidateNode(property.Value, propertySchema, $"{path}.{property.Name}", errors); + else if (closesObject) + errors.Add($"{path}.{property.Name}: additional property is not allowed"); + } + } + + private static bool TypeMatches(JsonElement value, string type) => type switch + { + "object" => value.ValueKind == JsonValueKind.Object, + "array" => value.ValueKind == JsonValueKind.Array, + "string" => value.ValueKind == JsonValueKind.String, + "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && number == Math.Truncate(number), + "number" => value.ValueKind == JsonValueKind.Number, + "boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, + "null" => value.ValueKind == JsonValueKind.Null, + _ => true, + }; + + private static bool JsonValueEquals(JsonElement left, JsonElement right) + { + if (left.ValueKind != right.ValueKind) return false; + + return left.ValueKind switch + { + JsonValueKind.String => left.GetString() == right.GetString(), + JsonValueKind.Number => left.TryGetDouble(out var leftNumber) && right.TryGetDouble(out var rightNumber) && leftNumber == rightNumber, + JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, + _ => left.GetRawText() == right.GetRawText(), + }; + } + + private static string KindName(JsonValueKind kind) => kind switch + { + JsonValueKind.True or JsonValueKind.False => "boolean", + _ => kind.ToString().ToLowerInvariant(), + }; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs new file mode 100644 index 000000000..e1e776f56 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs @@ -0,0 +1,17 @@ +using CodeSpace.Core.Services.Slugs; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// The only slug derivation entry point delegates to , whose output is pinned by +/// golden-vector tests. If the result is empty (for example, all-Chinese or symbol-only input), callers must report +/// an error instead of silently deriving a fallback from an id; this slug becomes a permanent string in third-party configuration. +/// +public static class ExternalTriggerSlug +{ + public const string TypeKey = "trigger.external"; + + public static string Derive(string? authorInput) => Slug.Slugify(authorInput); + + public static bool IsUsable(string? derived) => !string.IsNullOrWhiteSpace(derived); +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs new file mode 100644 index 000000000..cd58d9e49 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class ExternalTriggerPayloadValidatorTests +{ + private const string Shape = """ + { "type": "object", + "properties": { "service": { "type": "string" }, "count": { "type": "integer" } }, + "required": ["service"], + "additionalProperties": false } + """; + + [Fact] + public void A_conforming_body_passes() + { + ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "checkout", "count": 3 }"""), Schema(Shape)).ShouldBeEmpty(); + } + + [Fact] + public void A_missing_required_field_is_named() + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema("""{ "count": 1 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.service") && e.Contains("required")); + } + + [Fact] + public void A_wrong_type_is_named() + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema("""{ "service": 7 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.service")); + } + + [Fact] + public void An_extra_property_is_refused_when_the_schema_closes_the_object() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "unexpected": true }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.unexpected")); + } + + [Fact] + public void An_extra_property_is_allowed_when_the_schema_leaves_it_open() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "extra": 1 }"""), + Schema("""{ "type": "object", "properties": { "service": { "type": "string" } } }""")); + errors.ShouldBeEmpty(); + } + + [Theory] + [InlineData("object", "{}", true)] + [InlineData("array", "[]", true)] + [InlineData("string", "\"text\"", true)] + [InlineData("integer", "3", true)] + [InlineData("integer", "3.5", false)] + [InlineData("number", "3.5", true)] + [InlineData("boolean", "true", true)] + [InlineData("null", "null", true)] + public void Json_types_are_checked(string type, string json, bool isValid) + { + var errors = ExternalTriggerPayloadValidator.Validate(Schema(json), Schema($"{{ \"type\": \"{type}\" }}")); + (errors.Count == 0).ShouldBe(isValid); + } + + [Fact] + public void An_enum_value_outside_the_contract_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"paused\""), Schema("""{ "type": "string", "enum": ["active", "disabled"] }""")); + errors.ShouldContain(e => e.Contains("$")); + } + + [Fact] + public void A_string_over_max_length_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"long\""), Schema("""{ "type": "string", "maxLength": 3 }""")); + errors.ShouldContain(e => e.Contains("$")); + } + + [Fact] + public void Array_items_are_validated_with_indexed_paths() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("[\"ok\", 7]"), Schema("""{ "type": "array", "items": { "type": "string" } }""")); + errors.ShouldContain(e => e.Contains("$[1]")); + } + + private static JsonElement Schema(string json) => JsonDocument.Parse(json).RootElement.Clone(); +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs new file mode 100644 index 000000000..a73ffdd9a --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs @@ -0,0 +1,31 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class ExternalTriggerSlugTests +{ + [Fact] + public void Type_key_is_the_external_trigger_identifier() => + ExternalTriggerSlug.TypeKey.ShouldBe("trigger.external"); + + [Theory] + [InlineData("prod 错误 报警", "prod")] + [InlineData("../../etc/passwd", "etc-passwd")] + [InlineData("Prod-Alerts", "prod-alerts")] + [InlineData("日本語", "")] + public void Derive_normalises_author_input(string input, string expected) => + ExternalTriggerSlug.Derive(input).ShouldBe(expected); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void An_unusable_name_is_not_a_slug(string? derived) => + ExternalTriggerSlug.IsUsable(derived).ShouldBeFalse(); + + [Fact] + public void A_nonblank_derived_slug_is_usable() => + ExternalTriggerSlug.IsUsable("prod-alerts").ShouldBeTrue(); +} From 6f1352a05ff269fd9a161853c5fe4d3dce691da7 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:16:45 +0800 Subject: [PATCH 02/26] fix: validate integer payloads without floating point rounding --- .../ExternalTriggerPayloadValidator.cs | 21 ++++++++++++++++++- .../ExternalTriggerPayloadValidatorTests.cs | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs index c701f4b92..22edb3ec6 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Numerics; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; @@ -76,13 +77,31 @@ private static void ValidateObject(JsonElement value, JsonElement schema, string "object" => value.ValueKind == JsonValueKind.Object, "array" => value.ValueKind == JsonValueKind.Array, "string" => value.ValueKind == JsonValueKind.String, - "integer" => value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && number == Math.Truncate(number), + "integer" => value.ValueKind == JsonValueKind.Number && IsInteger(value.GetRawText()), "number" => value.ValueKind == JsonValueKind.Number, "boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False, "null" => value.ValueKind == JsonValueKind.Null, _ => true, }; + private static bool IsInteger(string rawNumber) + { + var exponentIndex = rawNumber.IndexOfAny(['e', 'E']); + var mantissa = exponentIndex < 0 ? rawNumber : rawNumber[..exponentIndex]; + var exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(rawNumber[(exponentIndex + 1)..], System.Globalization.CultureInfo.InvariantCulture); + var decimalIndex = mantissa.IndexOf('.'); + var fractionalDigits = decimalIndex < 0 ? 0 : mantissa.Length - decimalIndex - 1; + var digits = mantissa.Replace(".", "", StringComparison.Ordinal).TrimStart('-'); + var significantDigits = digits.TrimEnd('0'); + if (significantDigits.Length == 0) return true; + + // JSON Schema treats 5.0 as an integer: accept a decimal token when its exact value has no fractional + // part after applying the exponent. Checking token digits avoids double rounding away a non-zero fraction. + var remainingFractionalDigits = new BigInteger(fractionalDigits) - exponent; + if (remainingFractionalDigits <= BigInteger.Zero) return true; + return remainingFractionalDigits <= digits.Length - significantDigits.Length; + } + private static bool JsonValueEquals(JsonElement left, JsonElement right) { if (left.ValueKind != right.ValueKind) return false; diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs index cd58d9e49..d7b856cc9 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -35,6 +35,23 @@ public void A_wrong_type_is_named() errors.ShouldContain(e => e.Contains("$.service")); } + [Fact] + public void A_large_fractional_number_is_not_an_integer() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""{ "service": "a", "count": 9007199254740992.1 }"""), Schema(Shape)); + errors.ShouldContain(e => e.Contains("$.count")); + } + + [Theory] + [InlineData("5.0")] + [InlineData("1e2")] + public void An_integer_value_with_decimal_or_exponent_notation_is_accepted(string number) + { + ExternalTriggerPayloadValidator.Validate( + Schema($"{{ \"service\": \"a\", \"count\": {number} }}"), Schema(Shape)).ShouldBeEmpty(); + } + [Fact] public void An_extra_property_is_refused_when_the_schema_closes_the_object() { @@ -58,6 +75,7 @@ public void An_extra_property_is_allowed_when_the_schema_leaves_it_open() [InlineData("string", "\"text\"", true)] [InlineData("integer", "3", true)] [InlineData("integer", "3.5", false)] + [InlineData("integer", "1e-1", false)] [InlineData("number", "3.5", true)] [InlineData("boolean", "true", true)] [InlineData("null", "null", true)] From b21b2abc705ce6b207fb907f42d7c727c5bd7a4b Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:29:36 +0800 Subject: [PATCH 03/26] feat: workflow_external_trigger storage and external trigger key sealing --- .../Persistence/Db/CodeSpaceDbContext.cs | 1 + .../0251_workflow_external_trigger.sql | 26 ++++++++ .../Entities/WorkflowExternalTrigger.cs | 17 +++++ .../WorkflowExternalTriggerConfiguration.cs | 30 +++++++++ .../ExternalTriggerKeySealer.cs | 29 +++++++++ .../ExternalTriggerStorageMigrationTests.cs | 64 +++++++++++++++++++ .../ExternalTriggerKeySealerTests.cs | 43 +++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql create mode 100644 backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs create mode 100644 backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs diff --git a/backend/src/CodeSpace.Core/Persistence/Db/CodeSpaceDbContext.cs b/backend/src/CodeSpace.Core/Persistence/Db/CodeSpaceDbContext.cs index bae1ef5c0..d70e538d4 100644 --- a/backend/src/CodeSpace.Core/Persistence/Db/CodeSpaceDbContext.cs +++ b/backend/src/CodeSpace.Core/Persistence/Db/CodeSpaceDbContext.cs @@ -43,6 +43,7 @@ public CodeSpaceDbContext(DbContextOptions options, ICurrent public DbSet Workflow => Set(); public DbSet WorkflowVersion => Set(); public DbSet WorkflowActivation => Set(); + public DbSet WorkflowExternalTrigger => Set(); public DbSet TaskRouteSnapshot => Set(); public DbSet WorkflowRun => Set(); public DbSet WorkflowRunExecutionAuthority => Set(); diff --git a/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql new file mode 100644 index 000000000..4c04bdf61 --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql @@ -0,0 +1,26 @@ +-- 0251_workflow_external_trigger.sql +-- External-trigger credentials live outside workflow_activation.config_jsonb because they have their own lifecycle and a +-- gated reveal must not expose a bearer key whenever any workflow definition is read. node_id is stable across saves while +-- ReplaceActivationsAsync soft-deletes and recreates every activation row, so activation_id would become stale. enabled is +-- intentionally absent: the activation already owns the switch, and a duplicate would create conflicting truth. + +CREATE TABLE workflow_external_trigger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES team(id), + workflow_id UUID NOT NULL REFERENCES workflow(id), + node_id TEXT NOT NULL, + slug TEXT NOT NULL, + key_digest TEXT NOT NULL, + key_ciphertext TEXT NOT NULL, + created_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID NOT NULL, + last_modified_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_modified_by UUID NOT NULL, + deleted_date TIMESTAMPTZ NULL +); +CREATE UNIQUE INDEX uq_wet_slug ON workflow_external_trigger (slug) WHERE deleted_date IS NULL; +CREATE INDEX idx_wet_key_digest ON workflow_external_trigger (key_digest); +CREATE INDEX idx_wet_workflow_node ON workflow_external_trigger (workflow_id, node_id); +CREATE INDEX idx_wet_team ON workflow_external_trigger (team_id); +COMMENT ON TABLE workflow_external_trigger IS + 'Per-node credential and routing identity for a trigger.external node. key_digest verifies a presented key; key_ciphertext is the AES-GCM sealed form a workflows.write-gated reveal opens.'; diff --git a/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs new file mode 100644 index 000000000..27a5fa685 --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs @@ -0,0 +1,17 @@ +namespace CodeSpace.Core.Persistence.Entities; + +public class WorkflowExternalTrigger : IEntity, IAuditable +{ + public Guid Id { get; set; } + public Guid TeamId { get; set; } + public Guid WorkflowId { get; set; } + public string NodeId { get; set; } = default!; + public string Slug { get; set; } = default!; + public string KeyDigest { get; set; } = default!; + public string KeyCiphertext { get; set; } = default!; + public DateTimeOffset CreatedDate { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset LastModifiedDate { get; set; } + public Guid LastModifiedBy { get; set; } + public DateTimeOffset? DeletedDate { get; set; } +} diff --git a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs new file mode 100644 index 000000000..7f9a3672d --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs @@ -0,0 +1,30 @@ +using CodeSpace.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CodeSpace.Core.Persistence.EntityConfigurations; + +public class WorkflowExternalTriggerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("workflow_external_trigger"); + builder.HasKey(t => t.Id); + builder.Property(t => t.TeamId).HasColumnName("team_id"); + builder.Property(t => t.WorkflowId).HasColumnName("workflow_id"); + builder.Property(t => t.NodeId).HasColumnName("node_id").HasMaxLength(64); + builder.Property(t => t.Slug).HasMaxLength(64); + builder.Property(t => t.KeyDigest).HasColumnName("key_digest"); + builder.Property(t => t.KeyCiphertext).HasColumnName("key_ciphertext"); + builder.Property(t => t.CreatedDate).HasColumnName("created_date"); + builder.Property(t => t.CreatedBy).HasColumnName("created_by"); + builder.Property(t => t.LastModifiedDate).HasColumnName("last_modified_date"); + builder.Property(t => t.LastModifiedBy).HasColumnName("last_modified_by"); + builder.Property(t => t.DeletedDate).HasColumnName("deleted_date"); + + builder.HasIndex(t => t.Slug).IsUnique().HasFilter("deleted_date IS NULL").HasDatabaseName("uq_wet_slug"); + builder.HasIndex(t => t.KeyDigest).HasDatabaseName("idx_wet_key_digest"); + builder.HasIndex(t => new { t.WorkflowId, t.NodeId }).HasDatabaseName("idx_wet_workflow_node"); + builder.HasIndex(t => t.TeamId).HasDatabaseName("idx_wet_team"); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs new file mode 100644 index 000000000..801d12e5d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerKeySealer.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Services.Variables; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Stores a SHA-256 digest for anonymous-route key lookup and AES-GCM ciphertext for permission-gated reveal. Data Protection +/// is not used because its key ring is stored unencrypted in the same database (data_protection_keys). +/// +public sealed class ExternalTriggerKeySealer : ISingletonDependency +{ + private readonly IVariableValueEncryption _encryption; + + public ExternalTriggerKeySealer(IVariableValueEncryption encryption) => _encryption = encryption; + + public SealedExternalTriggerKey Mint() + { + var key = "ext_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).Replace('+', '-').Replace('/', '_').TrimEnd('='); + return new SealedExternalTriggerKey(key, Digest(key), Convert.ToBase64String(_encryption.Encrypt(key))); + } + + public static string Digest(string key) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(key))); + + public string Open(string ciphertext) => _encryption.Decrypt(Convert.FromBase64String(ciphertext)); +} + +public sealed record SealedExternalTriggerKey(string Key, string Digest, string Ciphertext); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs new file mode 100644 index 000000000..67af1e946 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs @@ -0,0 +1,64 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; +using Xunit; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerStorageMigrationTests(PostgresFixture fixture) +{ + [Fact] + public async Task The_slug_is_unique_instance_wide_while_live() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); + var workflowId = await SeedWorkflowAsync(teamId, userId); + var slug = "prod-alerts-" + Guid.NewGuid().ToString("N"); + await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true)); + + await Should.ThrowAsync(() => SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true))); + } + + [Fact] + public async Task A_soft_deleted_slug_is_reusable() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); + var workflowId = await SeedWorkflowAsync(teamId, userId); + var slug = "prod-alerts-" + Guid.NewGuid().ToString("N"); + await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: false)); + await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true)); + } + + private async Task SeedWorkflowAsync(Guid teamId, Guid userId) + { + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var workflow = new Workflow { Id = id, TeamId = teamId, Slug = "external-trigger-" + id.ToString("N")[..8], Name = "external-trigger", DefinitionJson = "{}", CreatedDate = now, CreatedBy = userId, LastModifiedDate = now, LastModifiedBy = userId }; + db.Workflow.Add(workflow); + await db.SaveChangesAsync(); + return workflow.Id; + } + + private async Task SeedAsync(WorkflowExternalTrigger row) + { + using var scope = fixture.BeginScope(); + scope.Resolve().WorkflowExternalTrigger.Add(row); + await scope.Resolve().SaveChangesAsync(); + } + + private static WorkflowExternalTrigger NewRow(Guid teamId, Guid workflowId, Guid userId, string slug, bool live) => new() + { + Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "notify", Slug = slug, + KeyDigest = new string('a', 64), KeyCiphertext = "sealed", CreatedDate = DateTimeOffset.UtcNow, + CreatedBy = userId, LastModifiedDate = DateTimeOffset.UtcNow, LastModifiedBy = userId, + DeletedDate = live ? null : DateTimeOffset.UtcNow, + }; +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs new file mode 100644 index 000000000..9e892c32c --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerKeySealerTests.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; +using CodeSpace.Core.Services.Variables; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; +using Xunit; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerKeySealerTests +{ + [Fact] + public void A_minted_key_round_trips_through_its_own_digest_and_ciphertext() + { + var sealer = new ExternalTriggerKeySealer(new FixedEncryption()); + var minted = sealer.Mint(); + minted.Key.ShouldStartWith("ext_"); + minted.Key.Length.ShouldBe(47); + minted.Digest.ShouldBe(ExternalTriggerKeySealer.Digest(minted.Key)); + sealer.Open(minted.Ciphertext).ShouldBe(minted.Key); + } + + [Fact] + public void The_digest_is_lowercase_hex_sha256_of_the_key() + { + var key = new ExternalTriggerKeySealer(new FixedEncryption()).Mint().Key; + ExternalTriggerKeySealer.Digest(key).ShouldBe(Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(key)))); + } + + [Fact] + public void Two_mints_produce_different_keys() + { + var sealer = new ExternalTriggerKeySealer(new FixedEncryption()); + sealer.Mint().Key.ShouldNotBe(sealer.Mint().Key); + } + + private sealed class FixedEncryption : IVariableValueEncryption + { + public byte[] Encrypt(string plaintext) => Encoding.UTF8.GetBytes("sealed:" + plaintext); + public string Decrypt(byte[] envelope) => Encoding.UTF8.GetString(envelope)["sealed:".Length..]; + } +} From 14bea2c8d2dd11e91db6eacec50fd49318c2ac01 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:40:56 +0800 Subject: [PATCH 04/26] feat: trigger.external node runtime --- .../Nodes/Builtin/TriggerExternalNode.cs | 42 +++++++++++++++++++ .../Plugins/Builtin/CoreFlowPlugin.cs | 1 + .../Workflows/BuiltinPluginModuleTests.cs | 5 ++- .../Workflows/NodeManifestContractTests.cs | 27 ++++++++++++ .../Workflows/TriggerExternalNodeTests.cs | 17 ++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs new file mode 100644 index 000000000..5c9d70093 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs @@ -0,0 +1,42 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Workflows.Nodes.Builtin; + +/// +/// Accepts runs started by an authenticated external API request. +/// IsManual must remain false: deriveActivations projects an activation row from that +/// flag, and anonymous runs need its lineage or ExecutionAuthorityService rejects every call. +/// +public sealed class TriggerExternalNode : INodeRuntime +{ + public string TypeKey => ExternalTriggerSlug.TypeKey; + + public NodeManifest Manifest { get; } = new() + { + DisplayName = "External API", + Category = "Triggers", + Kind = NodeKind.Trigger, + IconKey = "webhook", + IsManual = false, + ConfigSchema = SchemaBuilder.Parse(""" + { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "inputSchema": { "type": "object" } + }, + "required": ["slug"], + "additionalProperties": false + } + """), + InputSchema = SchemaBuilder.EmptyObject(), + OutputSchema = SchemaBuilder.EmptyObject(), + }; + + public Task RunAsync(NodeRunContext context, CancellationToken cancellationToken) + { + var outputs = context.Scope.Trigger.ToDictionary(kv => kv.Key, kv => kv.Value); + return Task.FromResult(NodeResult.Ok(outputs)); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs b/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs index cfb4891ae..e17466021 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Plugins/Builtin/CoreFlowPlugin.cs @@ -21,6 +21,7 @@ public sealed class CoreFlowPlugin : IPluginModule public IReadOnlyList Nodes { get; } = new[] { typeof(TriggerManualNode), + typeof(TriggerExternalNode), typeof(TriggerScheduleNode), typeof(TerminalNode), typeof(LogicIfNode), diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs index d480ad2a1..d4d43d3cd 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/BuiltinPluginModuleTests.cs @@ -20,6 +20,7 @@ public void Core_flow_plugin_lists_terminal_branch_and_iteration_primitives() module.Name.ShouldBe("Core Flow"); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerManualNode)); + module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerExternalNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TriggerScheduleNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.TerminalNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.LogicIfNode)); @@ -37,7 +38,7 @@ public void Core_flow_plugin_lists_terminal_branch_and_iteration_primitives() module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowTryStartNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowMapNode)); module.Nodes.ShouldContain(typeof(CodeSpace.Core.Services.Workflows.Nodes.Builtin.FlowMapStartNode)); - module.Nodes.Count.ShouldBe(18); + module.Nodes.Count.ShouldBe(19); module.RunSourceMatchers.ShouldBeEmpty("manual + schedule triggers subscribe to no inbound event (schedule is producer-driven), so Core Flow still ships zero matchers"); } @@ -122,6 +123,6 @@ public void All_builtin_plugins_together_cover_every_builtin_node() }; var total = all.SelectMany(p => p.Nodes).Distinct().Count(); - total.ShouldBe(43, "43 builtin node types across 6 domain plugins — incl. the Git plugin's git.integrate_run run-sourced integrate (P4) — adjust this number when adding a builtin"); + total.ShouldBe(44, "44 builtin node types across 6 domain plugins — adjust this number when adding a builtin"); } } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs index 97b97ed88..45c3f5800 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/NodeManifestContractTests.cs @@ -27,6 +27,7 @@ public static IEnumerable AllPureNodes() yield return new object[] { new TriggerPushNode() }; yield return new object[] { new TriggerScheduleNode() }; yield return new object[] { new TriggerManualNode() }; + yield return new object[] { new TriggerExternalNode() }; yield return new object[] { new TerminalNode() }; } @@ -80,6 +81,7 @@ public void Trigger_nodes_declare_trigger_kind() new TriggerPushNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); new TriggerScheduleNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); new TriggerManualNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); + new TriggerExternalNode().Manifest.Kind.ShouldBe(NodeKind.Trigger); } [Fact] @@ -103,6 +105,8 @@ public void Only_the_manual_trigger_declares_IsManual_true() "event triggers subscribe to webhooks and MUST keep IsManual=false so deriveActivations still emits their activation"); new TriggerScheduleNode().Manifest.IsManual.ShouldBeFalse( "the schedule trigger MUST keep IsManual=false so deriveActivations emits the activation row the producer queries"); + new TriggerExternalNode().Manifest.IsManual.ShouldBeFalse( + "anonymous runs must carry activation lineage, so deriveActivations MUST project this trigger's activation row"); } [Fact] @@ -130,6 +134,29 @@ public async Task Manual_trigger_echoes_run_payload_as_outputs() result.Outputs["ticket"].GetString().ShouldBe("ABC-123"); } + [Fact] + public async Task External_trigger_echoes_run_payload_as_outputs() + { + var payload = JsonDocument.Parse("""{"ticket":"ABC-123"}""").RootElement + .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + + var context = new NodeRunContext + { + Inputs = new Dictionary(), + Config = new Dictionary(), + RawInputs = JsonDocument.Parse("{}").RootElement, + RawConfig = JsonDocument.Parse("{}").RootElement, + Scope = new NodeRunScope { Trigger = payload }, + Logger = NullLogger.Instance, + Observability = NodeObservability.NoOp, + }; + + var result = await new TriggerExternalNode().RunAsync(context, CancellationToken.None); + + result.Status.ShouldBe(NodeStatus.Success); + result.Outputs["ticket"].GetString().ShouldBe("ABC-123"); + } + [Fact] public void Terminal_node_declares_terminal_kind() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs new file mode 100644 index 000000000..db581a84e --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs @@ -0,0 +1,17 @@ +using CodeSpace.Core.Services.Workflows.Nodes.Builtin; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class TriggerExternalNodeTests +{ + [Fact] + public void The_manifest_declares_a_slug_and_an_input_schema_for_the_editor() + { + var config = new TriggerExternalNode().Manifest.ConfigSchema; + config.GetProperty("properties").GetProperty("slug").GetProperty("type").GetString().ShouldBe("string"); + config.GetProperty("required").EnumerateArray().Any(e => e.GetString() == "slug").ShouldBeTrue(); + config.GetProperty("properties").TryGetProperty("inputSchema", out _).ShouldBeTrue(); + } +} From 417ba381b54060d793c32be3057fe78301a6a95e Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:02:22 +0800 Subject: [PATCH 05/26] feat: external trigger ingress with key auth, contract check and caller-owned idempotency --- .../ExternalTriggerIdempotency.cs | 16 + .../ExternalTriggerIngress.cs | 132 +++++++++ .../IExternalTriggerIngress.cs | 14 + .../ExternalTriggerIngressFlowTests.cs | 279 ++++++++++++++++++ .../ExternalTriggerIdempotencyTests.cs | 35 +++ 5 files changed, 476 insertions(+) create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs new file mode 100644 index 000000000..bb39413ea --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs @@ -0,0 +1,16 @@ +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// No header means no idempotency key: deduplication belongs to the caller. Identical bodies can be retries or +/// distinct heartbeats and periodic alerts; any server-derived fallback key would silently swallow real events. +/// +public static class ExternalTriggerIdempotency +{ + public const int MaxLength = 200; + + public static bool IsWellFormed(string? value) => + !string.IsNullOrEmpty(value) && value.Length <= MaxLength && + value.All(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '_' or '~' or '-'); + + public static string KeyFor(string value, Guid activationId) => $"{ExternalTriggerSlug.TypeKey}:{value}:{activationId:N}"; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs new file mode 100644 index 000000000..7dc904549 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodeSpace.Core.Middlewares.Transactional; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents.Authority.Exceptions; +using CodeSpace.Core.Services.Completion.Exceptions; +using CodeSpace.Core.Services.Workflows.Dispatch; +using CodeSpace.Core.Services.Workflows.RunSources; +using CodeSpace.Messages.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// SourceType must equal the activation TypeKey: ActivationTypeFor only translates cron, so any other value fails +/// VerifyActivation with trigger-snapshot-mismatch. Anonymous runs also need the activation's id and authority snapshot. +/// Each call resolves the live activation afresh because ReplaceActivationsAsync rebuilds every row on workflow save. +/// No Idempotency-Key means no deduplication: identical bodies can represent distinct real events, not just retries. +/// +public sealed class ExternalTriggerIngress( + CodeSpaceDbContext db, + IRunStarter runStarter, + IWorkflowRunDispatcher runDispatcher, + IPostCommitActions postCommit, + IIngestionAuditor auditor, + ILogger logger) : IExternalTriggerIngress +{ + public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct) + { + var trigger = await db.WorkflowExternalTrigger.AsNoTracking() + .SingleOrDefaultAsync(t => t.Slug == call.Slug && t.DeletedDate == null, ct).ConfigureAwait(false); + if (trigger is null) return Outcome(ExternalTriggerOutcomeKind.NotFound); + + var activations = await db.WorkflowActivation.AsNoTracking().Include(a => a.Workflow) + .Where(a => a.WorkflowId == trigger.WorkflowId && a.TypeKey == ExternalTriggerSlug.TypeKey + && a.Enabled && a.DeletedDate == null && a.Workflow.Enabled && a.Workflow.DeletedDate == null) + .ToListAsync(ct).ConfigureAwait(false); + if (activations.Count != 1) + { + if (activations.Count > 1) + logger.LogWarning("External trigger workflow {WorkflowId} has {Count} live activations; refusing ambiguous endpoint", trigger.WorkflowId, activations.Count); + return Outcome(ExternalTriggerOutcomeKind.NotFound); + } + var activation = activations[0]; + + if (call.PresentedKey is null || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(call.PresentedKey)), Encoding.ASCII.GetBytes(trigger.KeyDigest))) + return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + + if (call.IdempotencyRejected) + return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, + errors: [$"Idempotency-Key must be at most {ExternalTriggerIdempotency.MaxLength} characters from [A-Za-z0-9._~-]"]); + + JsonDocument body; + try { body = JsonDocument.Parse(call.RawBody); } + catch (JsonException) { return InvalidBody(); } + using (body) + { + if (body.RootElement.ValueKind != JsonValueKind.Object) return InvalidBody(); + using var config = JsonDocument.Parse(activation.ConfigJson); + using var fallback = JsonDocument.Parse("""{"type":"object"}"""); + var schema = config.RootElement.ValueKind == JsonValueKind.Object + && config.RootElement.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object + ? declared : fallback.RootElement; + var errors = ExternalTriggerPayloadValidator.Validate(body.RootElement, schema); + if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); + + var envelope = new RunSourceEnvelope + { + TeamId = activation.Workflow.TeamId, + WorkflowId = activation.WorkflowId, + WorkflowVersion = activation.Workflow.LatestVersion, + SourceType = ExternalTriggerSlug.TypeKey, + ActorType = WorkflowRunActorTypes.Webhook, + ActorId = null, + CreatedBy = SystemUsers.SeederId, + ActivationId = activation.Id, + ActivationSnapshotJson = ActivationAuthoritySnapshot.Serialize(activation), + NormalizedPayloadJson = body.RootElement.GetRawText(), + IdempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, activation.Id), + }; + + Guid runId; + try { runId = await runStarter.StartAsync(envelope, ct).ConfigureAwait(false); } + catch (AgentAuthorityDeniedException ex) + { + await AuditRefusalAsync(activation, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Accepted); + } + catch (CompletionAdmissionRefusedException ex) + { + await AuditRefusalAsync(activation, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Accepted); + } + + if (runId == Guid.Empty) + { + var existing = envelope.IdempotencyKey is null ? null : await db.WorkflowRun.AsNoTracking() + .Where(r => r.RunRequest.IdempotencyKey == envelope.IdempotencyKey) + .Select(r => (Guid?)r.Id).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (existing.HasValue) return Outcome(ExternalTriggerOutcomeKind.Deduplicated, existing.Value); + logger.LogWarning("External trigger {Slug} was deduplicated but its run could not be found", call.Slug); + return Outcome(ExternalTriggerOutcomeKind.Accepted); + } + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await postCommit.RunAfterCommitAsync(token => runDispatcher.DispatchAsync(runId, token), ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.Accepted, runId); + } + } + + private async Task AuditRefusalAsync(WorkflowActivation activation, ExternalTriggerCall call, string reason, string detail, CancellationToken ct) + { + logger.LogWarning("External trigger activation {ActivationId} workflow {WorkflowId} refused: {Reason} {Detail}", activation.Id, activation.WorkflowId, reason, detail); + await auditor.WriteWebhookRejectedAsync(new WebhookRejectionContext + { + TeamId = activation.Workflow.TeamId, + SourceType = ExternalTriggerSlug.TypeKey, + ExternalEventId = call.IdempotencyKey, + Reason = reason, + Detail = $"Activation {activation.Id} workflow {activation.WorkflowId}: {detail}", + DedupKey = $"rejected:{call.Slug}:{call.IdempotencyKey}", + }, ct).ConfigureAwait(false); + } + + private static ExternalTriggerOutcome InvalidBody() => Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: ["$: body must be a JSON object"]); + + private static ExternalTriggerOutcome Outcome(ExternalTriggerOutcomeKind kind, Guid runId = default, IReadOnlyList? errors = null) => new(kind, runId, errors ?? []); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs new file mode 100644 index 000000000..b863cd777 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs @@ -0,0 +1,14 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public interface IExternalTriggerIngress : IScopedDependency +{ + Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct); +} + +public sealed record ExternalTriggerCall(string Slug, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected); + +public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors); + +public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, InvalidPayload } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs new file mode 100644 index 000000000..2c0fdb243 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -0,0 +1,279 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Middlewares.Transactional; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public class ExternalTriggerIngressFlowTests(PostgresFixture fixture) +{ + private const string Config = """{"slug":"alerts","inputSchema":{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}}"""; + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid ActivationId, string Slug, string Key); + + [Fact] + public async Task A_correct_key_starts_a_run_that_carries_the_activation_lineage() + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var run = (await RunsAsync(seed)).ShouldHaveSingleItem(); + outcome.RunId.ShouldBe(run.Id); + run.RunRequest.SourceType.ShouldBe("trigger.external"); + run.RunRequest.ActorType.ShouldBe("webhook"); + run.RunRequest.ActorId.ShouldBeNull(); + run.RunRequest.ActivationId.ShouldBe(seed.ActivationId); + run.RunRequest.ActivationSnapshotJson.ShouldNotBeNullOrEmpty(); + using var snapshot = JsonDocument.Parse(run.RunRequest.ActivationSnapshotJson); + snapshot.RootElement.GetProperty("id").GetGuid().ShouldBe(seed.ActivationId); + using var payload = JsonDocument.Parse(run.RunRequest.NormalizedPayloadJson); + payload.RootElement.GetProperty("service").GetString().ShouldBe("api"); + run.Status.ShouldBe(WorkflowRunStatus.Enqueued); + } + + [Theory] + [InlineData("ext_wrong")] + [InlineData(null)] + public async Task The_wrong_or_missing_key_is_unauthorized_before_payload_validation(string? key) + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed, presentedKey: key, body: "bad", rejected: true)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Theory] + [InlineData("{\"service\":7}", "$.service")] + [InlineData("not json", "$: body must be a JSON object")] + [InlineData("[]", "$: body must be a JSON object")] + [InlineData("null", "$: body must be a JSON object")] + public async Task An_invalid_body_is_refused_without_writing_a_request_row(string body, string error) + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed, body: body); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + outcome.Errors.ShouldContain(e => e.Contains(error)); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_rejected_idempotency_header_precedes_body_validation() + { + var seed = await SeedAsync(); + var outcome = await ReceiveAsync(seed, body: "bad", rejected: true); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + outcome.Errors.ShouldBe(new[] { "Idempotency-Key must be at most 200 characters from [A-Za-z0-9._~-]" }); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task An_unknown_slug_is_not_found() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed with { Slug = "nope" })).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + } + + [Theory] + [InlineData("disabled-activation")] + [InlineData("deleted-activation")] + [InlineData("disabled-workflow")] + [InlineData("deleted-workflow")] + [InlineData("deleted-trigger")] + [InlineData("multiple-activations")] + public async Task An_unavailable_or_ambiguous_endpoint_is_not_found(string state) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScope()) + { + var db = scope.Resolve(); + var activation = await db.WorkflowActivation.SingleAsync(a => a.Id == seed.ActivationId); + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + switch (state) + { + case "disabled-activation": activation.Enabled = false; break; + case "deleted-activation": activation.DeletedDate = DateTimeOffset.UtcNow; break; + case "disabled-workflow": workflow.Enabled = false; break; + case "deleted-workflow": workflow.DeletedDate = DateTimeOffset.UtcNow; break; + case "deleted-trigger": (await db.WorkflowExternalTrigger.SingleAsync(t => t.Slug == seed.Slug)).DeletedDate = DateTimeOffset.UtcNow; break; + case "multiple-activations": db.WorkflowActivation.Add(Activation(seed, Guid.NewGuid(), Config)); break; + } + await db.SaveChangesAsync(); + } + (await ReceiveAsync(seed, presentedKey: "wrong", body: "bad")).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task Two_deliveries_without_an_idempotency_key_are_two_runs() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await RunsAsync(seed)).Length.ShouldBe(2); + } + + [Fact] + public async Task The_same_idempotency_key_twice_starts_one_run_and_returns_the_first_run_id() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "evt-1"); + var second = await ReceiveAsync(seed, idempotencyKey: "evt-1"); + first.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + second.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + second.RunId.ShouldBe(first.RunId); + (await RunsAsync(seed)).Length.ShouldBe(1); + } + + [Fact] + public async Task Concurrent_deliveries_of_one_key_start_exactly_one_run() + { + var seed = await SeedAsync(); + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => ReceiveAsync(seed, idempotencyKey: "evt-concurrent"))); + (await RunsAsync(seed)).Length.ShouldBe(1); + results.Select(r => r.RunId).Distinct().ShouldHaveSingleItem().ShouldNotBe(Guid.Empty); + results.Count(r => r.Kind == ExternalTriggerOutcomeKind.Accepted).ShouldBe(1); + results.Count(r => r.Kind == ExternalTriggerOutcomeKind.Deduplicated).ShouldBe(7); + } + + [Fact] + public async Task A_recreated_activation_is_resolved_fresh_even_in_the_same_scope() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var ingress = scope.Resolve(); + var call = new ExternalTriggerCall(seed.Slug, seed.Key, "evt-1", """{"service":"api"}""", false); + (await ingress.ReceiveAsync(call, CancellationToken.None)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var replacement = Guid.NewGuid(); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + await db.WorkflowActivation.Where(a => a.Id == seed.ActivationId).ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedDate, DateTimeOffset.UtcNow)); + db.WorkflowActivation.Add(Activation(seed, replacement, Config)); + await db.SaveChangesAsync(); + } + var outcome = await ingress.ReceiveAsync(call, CancellationToken.None); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await RunsAsync(seed)).Single(r => r.Id == outcome.RunId).RunRequest.ActivationId.ShouldBe(replacement); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"inputSchema\":7}")] + public async Task A_missing_or_non_object_schema_imposes_no_extra_constraints(string config) + { + var seed = await SeedAsync(config); + (await ReceiveAsync(seed, body: "{}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Theory] + [InlineData(false, "agent.authority_denied")] + [InlineData(true, "completion_admission_refused")] + public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_audited(bool completion, string reason) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScope()) + { + var db = scope.Resolve(); + if (completion) + { + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var definition = JsonSerializer.Serialize(WorkflowsTestSeed.MinimalDefinition() with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); + workflow.LatestVersion = 2; + workflow.DefinitionJson = definition; + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 2, DefinitionJson = definition, DefinitionHash = "legacy", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = seed.UserId }); + } + else await db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync(); + await db.SaveChangesAsync(); + } + for (var i = 0; i < 2; i++) + { + var outcome = await ReceiveAsync(seed, idempotencyKey: "refused"); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + outcome.RunId.ShouldBe(Guid.Empty); + } + (await RunsAsync(seed)).ShouldBeEmpty(); + using var verify = fixture.BeginScope(); + var audit = (await verify.Resolve().WorkflowRunRequest.Where(r => r.TeamId == seed.TeamId).ToListAsync()).ShouldHaveSingleItem(); + audit.Status.ShouldBe(WorkflowRunRequestStatus.Rejected); + audit.SourceType.ShouldBe("trigger.external"); + audit.Error.ShouldContain(reason); + audit.IdempotencyKey.ShouldBe($"rejected:{seed.Slug}:refused"); + } + + [Fact] + public async Task Dispatch_waits_until_the_ambient_transaction_commits() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + Guid runId; + await using (var transaction = await db.Database.BeginTransactionAsync()) + { + var outcome = await scope.Resolve().ReceiveAsync(new(seed.Slug, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + runId = outcome.RunId; + (await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Pending); + (await RunsAsync(seed)).ShouldBeEmpty(); + await transaction.CommitAsync(); + } + await scope.Resolve().RunAllAsync(CancellationToken.None); + (await RunsAsync(seed)).ShouldHaveSingleItem().Status.ShouldBe(WorkflowRunStatus.Enqueued); + } + + private async Task SeedAsync(string config = Config) + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, inProcessPool: false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var db = scope.Resolve(); + var key = scope.Resolve().Mint(); + var seed = new Seed(teamId, userId, Guid.NewGuid(), Guid.NewGuid(), "alerts-" + Guid.NewGuid().ToString("N"), key.Key); + var graph = WorkflowsTestSeed.MinimalDefinition() with + { + Nodes = new[] + { + new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = WorkflowsTestSeed.Json(config), Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }, + }, + }; + var definition = JsonSerializer.Serialize(graph, WorkflowJson.Options); + db.Workflow.Add(new Workflow { Id = seed.WorkflowId, TeamId = teamId, Slug = seed.Slug, Name = "External ingress", DefinitionJson = definition, Enabled = true, CreatedBy = userId, LastModifiedBy = userId }); + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 1, DefinitionJson = definition, DefinitionHash = "test", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = userId }); + db.WorkflowActivation.Add(Activation(seed, seed.ActivationId, config)); + db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger { Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = seed.WorkflowId, NodeId = "start", Slug = seed.Slug, KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId }); + await db.SaveChangesAsync(); + return seed; + } + + private static WorkflowActivation Activation(Seed seed, Guid id, string config) => new() + { + Id = id, WorkflowId = seed.WorkflowId, TypeKey = "trigger.external", ConfigJson = config, + Enabled = true, CreatedBy = seed.UserId, LastModifiedBy = seed.UserId, + }; + + private async Task ReceiveAsync(Seed seed, string? presentedKey = "use-seeded-key", string? idempotencyKey = null, string body = "{\"service\": \"api\"}", bool rejected = false) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().ReceiveAsync(new(seed.Slug, presentedKey == "use-seeded-key" ? seed.Key : presentedKey, idempotencyKey, body, rejected), CancellationToken.None); + } + + private async Task RunsAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowRun.AsNoTracking().Include(r => r.RunRequest).Where(r => r.WorkflowId == seed.WorkflowId).ToArrayAsync(); + } + + private async Task RequestsAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowRunRequest.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).ToArrayAsync(); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs new file mode 100644 index 000000000..a0c65b6ac --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs @@ -0,0 +1,35 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public class ExternalTriggerIdempotencyTests +{ + [Theory] + [InlineData("abc-123_XYZ.~")] + public void A_well_formed_key_passes(string value) => ExternalTriggerIdempotency.IsWellFormed(value).ShouldBeTrue(); + + [Theory] + [InlineData("has space")] + [InlineData("has/slash")] + [InlineData("\u4e2d\u6587")] + [InlineData("")] + [InlineData(null)] + public void A_key_outside_the_charset_or_absent_is_refused(string? value) => ExternalTriggerIdempotency.IsWellFormed(value).ShouldBeFalse(); + + [Fact] + public void An_over_long_key_is_refused() => ExternalTriggerIdempotency.IsWellFormed(new string('a', 201)).ShouldBeFalse(); + + [Fact] + public void A_maximum_length_key_passes() => ExternalTriggerIdempotency.IsWellFormed(new string('a', 200)).ShouldBeTrue(); + + [Fact] + public void The_key_scopes_to_the_activation_so_two_triggers_never_share_it() + { + var a = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("11111111-1111-1111-1111-111111111111")); + var b = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("22222222-2222-2222-2222-222222222222")); + a.ShouldNotBe(b); + a.ShouldBe("trigger.external:evt-1:11111111111111111111111111111111"); + } +} From 3407cbeede3eb1f7c2d61a829a4774cccda5daee Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:19:46 +0800 Subject: [PATCH 06/26] fix: bind external trigger ingress to the current version node --- .../ExternalTriggerIngress.cs | 17 +++- .../ExternalTriggerIngressFlowTests.cs | 83 ++++++++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs index 7dc904549..59d353418 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -9,6 +9,7 @@ using CodeSpace.Core.Services.Workflows.Dispatch; using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Workflows; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -18,6 +19,8 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; /// SourceType must equal the activation TypeKey: ActivationTypeFor only translates cron, so any other value fails /// VerifyActivation with trigger-snapshot-mismatch. Anonymous runs also need the activation's id and authority snapshot. /// Each call resolves the live activation afresh because ReplaceActivationsAsync rebuilds every row on workflow save. +/// The latest version's node id binds the credential to its original node; replacing a node must not revive its old key. +/// Its node config also supplies the input contract so identity and validation use the same definition version. /// No Idempotency-Key means no deduplication: identical bodies can represent distinct real events, not just retries. /// public sealed class ExternalTriggerIngress( @@ -46,6 +49,15 @@ public async Task ReceiveAsync(ExternalTriggerCall call, } var activation = activations[0]; + var definitionJson = await db.WorkflowVersion.AsNoTracking() + .Where(v => v.WorkflowId == activation.WorkflowId && v.Version == activation.Workflow.LatestVersion) + .Select(v => v.DefinitionJson).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (definitionJson is null) return Outcome(ExternalTriggerOutcomeKind.NotFound); + var definition = JsonSerializer.Deserialize(definitionJson, WorkflowJson.Options); + var nodes = definition?.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToList(); + if (nodes is not { Count: 1 } || nodes[0].Id != trigger.NodeId) return Outcome(ExternalTriggerOutcomeKind.NotFound); + var config = nodes[0].Config; + if (call.PresentedKey is null || !CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(call.PresentedKey)), Encoding.ASCII.GetBytes(trigger.KeyDigest))) return Outcome(ExternalTriggerOutcomeKind.Unauthorized); @@ -60,10 +72,9 @@ public async Task ReceiveAsync(ExternalTriggerCall call, using (body) { if (body.RootElement.ValueKind != JsonValueKind.Object) return InvalidBody(); - using var config = JsonDocument.Parse(activation.ConfigJson); using var fallback = JsonDocument.Parse("""{"type":"object"}"""); - var schema = config.RootElement.ValueKind == JsonValueKind.Object - && config.RootElement.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object + var schema = config.ValueKind == JsonValueKind.Object + && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object ? declared : fallback.RootElement; var errors = ExternalTriggerPayloadValidator.Validate(body.RootElement, schema); if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs index 2c0fdb243..53c9b7517 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -4,13 +4,17 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.Dispatch; using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; using CodeSpace.IntegrationTests.Workflows.Infrastructure; using CodeSpace.Messages.Constants; using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Shouldly; namespace CodeSpace.IntegrationTests.Workflows; @@ -83,6 +87,79 @@ public async Task An_unknown_slug_is_not_found() (await ReceiveAsync(seed with { Slug = "nope" })).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task A_trigger_for_a_replaced_node_is_not_found_before_authentication(bool correctKey) + { + var seed = await SeedAsync(); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var trigger = await db.WorkflowExternalTrigger.SingleAsync(t => t.Slug == seed.Slug); + trigger.NodeId = "removed-node"; + await db.SaveChangesAsync(); + } + + var outcome = await ReceiveAsync(seed, presentedKey: correctKey ? seed.Key : "wrong"); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await RunsAsync(seed)).ShouldBeEmpty(); + (await RequestsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_deduplicated_delivery_without_a_matching_run_is_accepted_and_warned_without_dispatch() + { + var seed = await SeedAsync(); + var logger = new IngressWarnings(); + var dispatcher = new IngressDispatches(); + using var scope = fixture.BeginScope(b => + { + b.RegisterType().As().InstancePerLifetimeScope(); + b.RegisterInstance(logger).As>(); + b.RegisterInstance(dispatcher).As(); + }); + var jobs = scope.Resolve(); + var before = jobs.Calls.Count; + + var outcome = await scope.Resolve().ReceiveAsync( + new(seed.Slug, seed.Key, "missing-run", """{"service":"api"}""", false), CancellationToken.None); + + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + outcome.RunId.ShouldBe(Guid.Empty); + dispatcher.Calls.ShouldBe(0); + jobs.Calls.Count.ShouldBe(before); + (await RunsAsync(seed)).ShouldBeEmpty(); + (await RequestsAsync(seed)).ShouldBeEmpty(); + logger.Warnings.ShouldContain(w => w.Contains(seed.Slug) && w.Contains("run could not be found")); + } + + private sealed class EmptyRunStarter : IRunStarter + { + public Task StartAsync(RunSourceEnvelope envelope, CancellationToken cancellationToken) => Task.FromResult(Guid.Empty); + } + + private sealed class IngressDispatches : IWorkflowRunDispatcher + { + public int Calls { get; private set; } + public Task DispatchAsync(Guid runId, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(true); + } + } + + private sealed class IngressWarnings : ILogger + { + public List Warnings { get; } = []; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) Warnings.Add(formatter(state, exception)); + } + } + [Theory] [InlineData("disabled-activation")] [InlineData("deleted-activation")] @@ -187,7 +264,8 @@ public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_aud if (completion) { var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); - var definition = JsonSerializer.Serialize(WorkflowsTestSeed.MinimalDefinition() with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); + var graph = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + var definition = JsonSerializer.Serialize(graph with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); workflow.LatestVersion = 2; workflow.DefinitionJson = definition; db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 2, DefinitionJson = definition, DefinitionHash = "legacy", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = seed.UserId }); @@ -206,7 +284,8 @@ public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_aud var audit = (await verify.Resolve().WorkflowRunRequest.Where(r => r.TeamId == seed.TeamId).ToListAsync()).ShouldHaveSingleItem(); audit.Status.ShouldBe(WorkflowRunRequestStatus.Rejected); audit.SourceType.ShouldBe("trigger.external"); - audit.Error.ShouldContain(reason); + audit.Error.ShouldNotBeNull(); + audit.Error!.ShouldContain(reason); audit.IdempotencyKey.ShouldBe($"rejected:{seed.Slug}:refused"); } From 7db74e22b48630208bdcbd6e1dca19593e08bdc2 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:36:05 +0800 Subject: [PATCH 07/26] feat: anonymous POST /api/ext/{slug}/run ingress controller --- .../Controllers/ExternalTriggersController.cs | 54 +++++ .../WebhookIngressRateLimitExtension.cs | 2 +- .../ExternalTriggerEndpointE2ETests.cs | 221 ++++++++++++++++++ .../ExternalTriggerIngressContractTests.cs | 41 ++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs create mode 100644 backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs new file mode 100644 index 000000000..488d4d58d --- /dev/null +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -0,0 +1,54 @@ +using CodeSpace.Api.Extensions; +using CodeSpace.Api.Http; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using static CodeSpace.Api.Controllers.WebhooksController; + +namespace CodeSpace.Api.Controllers; + +/// +/// Anonymous ingress: the per-node key is the only credential, not a user or team session. Slugs are enumerable; +/// every unavailable endpoint therefore has the same 404, revealing none of its deletion or activation state. +/// Accepted calls remain 2xx even when admission cannot stage a run, so an unfixable refusal does not invite retries. +/// Payload rejection still answers 400, with the ingress's errors rather than a second validation contract here. +/// +[ApiController] +[Route("api/ext")] +[AllowAnonymous] +[EnableRateLimiting(WebhookIngressRateLimitExtension.PolicyName)] +public class ExternalTriggersController : ControllerBase +{ + private readonly IExternalTriggerIngress _ingress; + + public ExternalTriggersController(IExternalTriggerIngress ingress) { _ingress = ingress; } + + [HttpPost("{slug}/run")] + [RequestSizeLimit(MaxDeliveryBodyBytes)] + public async Task Run([FromRoute] string slug, [FromQuery] string? key, CancellationToken cancellationToken) + { + var body = await Request.ReadBoundedBodyAsync(MaxDeliveryBodyBytes, cancellationToken).ConfigureAwait(false); + if (body == null) return StatusCode(StatusCodes.Status413PayloadTooLarge); + + var authorization = Request.Headers.Authorization.ToString(); + var bearer = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? authorization[7..].Trim() : null; + var apiKey = Request.Headers["X-Api-Key"].ToString(); + var presentedKey = !string.IsNullOrEmpty(bearer) ? bearer : !string.IsNullOrEmpty(apiKey) ? apiKey : string.IsNullOrEmpty(key) ? null : key; + + var idempotencyHeader = Request.Headers["Idempotency-Key"].ToString(); + var idempotencyKey = string.IsNullOrWhiteSpace(idempotencyHeader) ? null : idempotencyHeader; + var idempotencyRejected = idempotencyKey != null && !ExternalTriggerIdempotency.IsWellFormed(idempotencyKey); + var outcome = await _ingress.ReceiveAsync(new ExternalTriggerCall(slug, presentedKey, idempotencyKey, body, idempotencyRejected), cancellationToken).ConfigureAwait(false); + + return outcome.Kind switch + { + ExternalTriggerOutcomeKind.NotFound => NotFound(), + ExternalTriggerOutcomeKind.Unauthorized => Unauthorized(), + ExternalTriggerOutcomeKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), + ExternalTriggerOutcomeKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), + ExternalTriggerOutcomeKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId == Guid.Empty ? (Guid?)null : outcome.RunId, deduplicated = false }), + _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), + }; + } +} diff --git a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs index 362ce3825..74a034b87 100644 --- a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs +++ b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs @@ -37,7 +37,7 @@ public static class WebhookIngressRateLimitExtension public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); /// The route values that name what a request is addressed to, in the order the ingress routes declare them. - private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token" }; + private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token", "slug" }; /// The one partition every non-ingress request shares in the global limiter — no limit. Never an address. private const string NotIngress = "not-ingress"; diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs new file mode 100644 index 000000000..d4b6df67f --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using CodeSpace.Api.Controllers; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Webhooks; + +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : IClassFixture +{ + private const string Body = """{"service":"a"}"""; + private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, string Slug, string Key); + + [Fact] + public async Task An_unknown_slug_is_404() + { + using var response = await PostAsync($"/api/ext/missing-{Guid.NewGuid():N}/run", "{}"); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Theory] + [InlineData(null)] + [InlineData("ext_wrong")] + [InlineData("Bearer ")] + public async Task A_missing_or_wrong_key_is_401_before_validation(string? authorization) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), "not json", authorization: authorization); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData("bearer")] + [InlineData("mixed-bearer")] + [InlineData("api-key")] + [InlineData("query")] + [InlineData("empty-bearer-fallback")] + [InlineData("wrong-scheme-fallback")] + [InlineData("api-key-before-query")] + public async Task The_supported_key_forms_start_a_run(string form) + { + var seed = await SeedAsync(); + var authorization = form switch + { + "bearer" => "Bearer " + seed.Key, + "mixed-bearer" => "bEaReR " + seed.Key, + "empty-bearer-fallback" => "Bearer ", + "wrong-scheme-fallback" => "Basic not-a-key", + _ => null, + }; + var apiKey = form is "api-key" or "empty-bearer-fallback" or "wrong-scheme-fallback" or "api-key-before-query" ? seed.Key : null; + var url = Url(seed) + (form == "query" ? "?key=" + seed.Key : form == "api-key-before-query" ? "?key=wrong" : ""); + using var response = await PostAsync(url, Body, authorization, apiKey); + response.StatusCode.ShouldBe(HttpStatusCode.Accepted, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); + var runs = await RunsAsync(seed); + payload.GetProperty("runId").GetGuid().ShouldBe(runs.ShouldHaveSingleItem()); + } + + [Fact] + public async Task A_bearer_key_takes_precedence_over_other_key_forms() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, Body, "Bearer ext_wrong", seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task A_body_over_the_limit_is_413() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), new string('x', (int)WebhooksController.MaxDeliveryBodyBytes + 1), apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.RequestEntityTooLarge); + await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task A_contract_violation_is_400_with_the_ingress_errors_unchanged() + { + var seed = await SeedAsync(); + const string invalidBody = """{"service":7}"""; + using var scope = factory.Services.CreateScope(); + var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.Slug, seed.Key, null, invalidBody, false), CancellationToken.None); + using var response = await PostAsync(Url(seed), invalidBody, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(expected.Errors); + (await response.Content.ReadAsStringAsync()).ShouldContain("$.service"); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task An_invalid_idempotency_key_is_400_and_starts_nothing(bool oversized) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: oversized ? new string('a', 201) : "bad key"); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "Idempotency-Key must be at most 200 characters from [A-Za-z0-9._~-]" }); + await ShouldHaveNoRequestsAsync(seed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task An_absent_or_blank_idempotency_key_does_not_deduplicate(string? idempotencyKey) + { + var seed = await SeedAsync(); + for (var i = 0; i < 2; i++) + { + using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: idempotencyKey); + response.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + (await RunsAsync(seed)).Length.ShouldBe(2); + } + + [Fact] + public async Task A_deduplicated_delivery_answers_200_with_the_first_run_id() + { + var seed = await SeedAsync(); + using var first = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: "evt-1"); + using var second = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: "evt-1"); + first.StatusCode.ShouldBe(HttpStatusCode.Accepted); + second.StatusCode.ShouldBe(HttpStatusCode.OK); + var accepted = await first.Content.ReadFromJsonAsync(); + var repeated = await second.Content.ReadFromJsonAsync(); + repeated.GetProperty("deduplicated").GetBoolean().ShouldBeTrue(); + repeated.GetProperty("runId").GetGuid().ShouldBe(accepted.GetProperty("runId").GetGuid()); + (await RunsAsync(seed)).ShouldHaveSingleItem().ShouldBe(accepted.GetProperty("runId").GetGuid()); + } + + [Fact] + public async Task An_admission_refusal_is_202_with_a_null_run_id() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); + using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.Accepted); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("runId").ValueKind.ShouldBe(JsonValueKind.Null); + payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); + (await RunsAsync(seed)).ShouldBeEmpty(); + } + + private static string Url(Seed seed) => $"/api/ext/{seed.Slug}/run"; + + private async Task PostAsync(string url, string body, string? authorization = null, string? apiKey = null, string? idempotencyKey = null) + { + using var client = new TenantHarness(factory).Client(); + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + if (authorization != null) request.Headers.TryAddWithoutValidation("Authorization", authorization); + if (apiKey != null) request.Headers.TryAddWithoutValidation("X-Api-Key", apiKey); + if (idempotencyKey != null) request.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey); + return await client.SendAsync(request); + } + + private async Task SeedAsync() + { + var harness = new TenantHarness(factory); + var userId = await harness.AccountAsync(); + var teamId = await harness.TeamAsync((userId, TeamRole.Owner)); + var slug = "alerts-" + Guid.NewGuid().ToString("N"); + var config = new { slug, inputSchema = new { type = "object", properties = new { service = new { type = "string" } }, required = new[] { "service" } } }; + using var response = await harness.SendAsync(HttpMethod.Post, "/api/workflows", userId, teamId, new + { + name = slug, enabled = true, + definition = new + { + schemaVersion = 1, + nodes = new object[] + { + new { id = "trigger", typeKey = "trigger.external", config, inputs = new { } }, + new { id = "end", typeKey = "builtin.terminal", config = new { }, inputs = new { } }, + }, + edges = new[] { new { from = "trigger", to = "end" } }, + }, + activations = new[] { new { typeKey = "trigger.external", enabled = true, config } }, + }); + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + return await SeedCredentialAsync(userId, teamId, payload.GetProperty("id").GetGuid(), slug); + } + + private async Task SeedCredentialAsync(Guid userId, Guid teamId, Guid workflowId, string slug) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var key = scope.ServiceProvider.GetRequiredService().Mint(); + db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger + { + Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "trigger", Slug = slug, + KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId, + }); + await db.SaveChangesAsync(); + return new(userId, teamId, workflowId, slug, key.Key); + } + + private Task RunsAsync(Seed seed) => new TenantHarness(factory).DbAsync(db => db.WorkflowRun.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).Select(r => r.Id).ToArrayAsync()); + + private async Task ShouldHaveNoRequestsAsync(Seed seed) + { + (await RunsAsync(seed)).ShouldBeEmpty(); + (await new TenantHarness(factory).DbAsync(db => db.WorkflowRunRequest.CountAsync(r => r.WorkflowId == seed.WorkflowId))).ShouldBe(0); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs new file mode 100644 index 000000000..3180621b0 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Reflection; +using CodeSpace.Api.Controllers; +using CodeSpace.Api.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Routing; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Trait("Category", "Integration")] +public sealed class ExternalTriggerIngressContractTests +{ + [Fact] + public void The_ext_route_is_anonymous_bounded_and_rate_limited_like_webhook_ingress() + { + var controller = typeof(WebhooksController).Assembly.GetType("CodeSpace.Api.Controllers.ExternalTriggersController").ShouldNotBeNull(); + controller.GetCustomAttribute().ShouldNotBeNull(); + controller.GetCustomAttribute().ShouldNotBeNull(); + controller.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("api/ext"); + var method = controller.GetMethod("Run").ShouldNotBeNull(); + method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{slug}/run"); + ((IRequestSizeLimitMetadata)method.GetCustomAttribute().ShouldNotBeNull()).MaxRequestBodySize.ShouldBe(WebhooksController.MaxDeliveryBodyBytes); + var policy = method.GetCustomAttribute() ?? controller.GetCustomAttribute(); + policy.ShouldNotBeNull().PolicyName.ShouldBe(WebhookIngressRateLimitExtension.PolicyName); + } + + [Fact] + public void The_target_partition_uses_the_slug_and_source_address() + { + var context = new DefaultHttpContext(); + context.Request.RouteValues = new RouteValueDictionary { ["slug"] = "alerts" }; + context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.7"); + + WebhookIngressRateLimitExtension.PartitionKey(context).ShouldBe("alerts|203.0.113.7"); + } +} From 18065f7d6a9b5b7b7a4f9d9ae3a20057e1d38dac Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:47:56 +0800 Subject: [PATCH 08/26] test: cover authenticated invalid external trigger bodies --- .../ExternalTriggerEndpointE2ETests.cs | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index d4b6df67f..c14083384 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -18,7 +18,7 @@ namespace CodeSpace.E2ETests.Webhooks; [Trait("Surface", "Http")] public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : IClassFixture { - private const string Body = """{"service":"a"}"""; + private const string BODY = """{"service":"a"}"""; private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, string Slug, string Key); [Fact] @@ -61,7 +61,7 @@ public async Task The_supported_key_forms_start_a_run(string form) }; var apiKey = form is "api-key" or "empty-bearer-fallback" or "wrong-scheme-fallback" or "api-key-before-query" ? seed.Key : null; var url = Url(seed) + (form == "query" ? "?key=" + seed.Key : form == "api-key-before-query" ? "?key=wrong" : ""); - using var response = await PostAsync(url, Body, authorization, apiKey); + using var response = await PostAsync(url, BODY, authorization, apiKey); response.StatusCode.ShouldBe(HttpStatusCode.Accepted, await TenantHarness.DescribeAsync(response)); var payload = await response.Content.ReadFromJsonAsync(); payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); @@ -73,7 +73,7 @@ public async Task The_supported_key_forms_start_a_run(string form) public async Task A_bearer_key_takes_precedence_over_other_key_forms() { var seed = await SeedAsync(); - using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, Body, "Bearer ext_wrong", seed.Key); + using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, BODY, "Bearer ext_wrong", seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); await ShouldHaveNoRequestsAsync(seed); } @@ -91,10 +91,10 @@ public async Task A_body_over_the_limit_is_413() public async Task A_contract_violation_is_400_with_the_ingress_errors_unchanged() { var seed = await SeedAsync(); - const string invalidBody = """{"service":7}"""; + const string INVALID_BODY = """{"service":7}"""; using var scope = factory.Services.CreateScope(); - var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.Slug, seed.Key, null, invalidBody, false), CancellationToken.None); - using var response = await PostAsync(Url(seed), invalidBody, apiKey: seed.Key); + var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.Slug, seed.Key, null, INVALID_BODY, false), CancellationToken.None); + using var response = await PostAsync(Url(seed), INVALID_BODY, apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var payload = await response.Content.ReadFromJsonAsync(); payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(expected.Errors); @@ -102,13 +102,27 @@ public async Task A_contract_violation_is_400_with_the_ingress_errors_unchanged( await ShouldHaveNoRequestsAsync(seed); } + [Theory] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("7")] + public async Task An_authenticated_malformed_or_non_object_body_is_400_and_writes_nothing(string body) + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed), body, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "$: body must be a JSON object" }); + await ShouldHaveNoRequestsAsync(seed); + } + [Theory] [InlineData(true)] [InlineData(false)] public async Task An_invalid_idempotency_key_is_400_and_starts_nothing(bool oversized) { var seed = await SeedAsync(); - using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: oversized ? new string('a', 201) : "bad key"); + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: oversized ? new string('a', 201) : "bad key"); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var payload = await response.Content.ReadFromJsonAsync(); payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(new[] { "Idempotency-Key must be at most 200 characters from [A-Za-z0-9._~-]" }); @@ -124,7 +138,7 @@ public async Task An_absent_or_blank_idempotency_key_does_not_deduplicate(string var seed = await SeedAsync(); for (var i = 0; i < 2; i++) { - using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: idempotencyKey); + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: idempotencyKey); response.StatusCode.ShouldBe(HttpStatusCode.Accepted); } (await RunsAsync(seed)).Length.ShouldBe(2); @@ -134,8 +148,8 @@ public async Task An_absent_or_blank_idempotency_key_does_not_deduplicate(string public async Task A_deduplicated_delivery_answers_200_with_the_first_run_id() { var seed = await SeedAsync(); - using var first = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: "evt-1"); - using var second = await PostAsync(Url(seed), Body, apiKey: seed.Key, idempotencyKey: "evt-1"); + using var first = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "evt-1"); + using var second = await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "evt-1"); first.StatusCode.ShouldBe(HttpStatusCode.Accepted); second.StatusCode.ShouldBe(HttpStatusCode.OK); var accepted = await first.Content.ReadFromJsonAsync(); @@ -150,7 +164,7 @@ public async Task An_admission_refusal_is_202_with_a_null_run_id() { var seed = await SeedAsync(); await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); - using var response = await PostAsync(Url(seed), Body, apiKey: seed.Key); + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.Accepted); var payload = await response.Content.ReadFromJsonAsync(); payload.GetProperty("runId").ValueKind.ShouldBe(JsonValueKind.Null); From 522f6d94bdf135494a4bd07bb593d53bf70000ed Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:03:26 +0800 Subject: [PATCH 09/26] feat: external trigger key reveal, rotate and rename plus save-path sync --- .../WorkflowExternalTriggersController.cs | 31 ++ .../ExternalTriggerCommandHandlers.cs | 51 ++++ .../ExternalTriggerDefinitionSync.cs | 29 ++ .../ExternalTriggerSlugConflictException.cs | 10 + .../ExternalTriggers/ExternalTriggerStore.cs | 82 ++++++ .../ExternalTriggers/IExternalTriggerStore.cs | 13 + .../Services/Workflows/WorkflowService.cs | 9 +- .../Workflows/RenameExternalTriggerCommand.cs | 14 + .../RevealExternalTriggerKeyCommand.cs | 12 + .../RotateExternalTriggerKeyCommand.cs | 12 + .../ExternalTriggerEndpointE2ETests.cs | 69 ++++- .../ExternalTriggerManagementFlowTests.cs | 273 ++++++++++++++++++ 12 files changed, 596 insertions(+), 9 deletions(-) create mode 100644 backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs create mode 100644 backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs create mode 100644 backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs create mode 100644 backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs create mode 100644 backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs diff --git a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs new file mode 100644 index 000000000..aa371e0a8 --- /dev/null +++ b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs @@ -0,0 +1,31 @@ +using CodeSpace.Messages.Commands.Workflows; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace CodeSpace.Api.Controllers; + +[ApiController] +[Route("api/workflows/external-triggers")] +public sealed class WorkflowExternalTriggersController(IMediator mediator) : ControllerBase +{ + [HttpGet("{slug}/key")] + public async Task Reveal([FromRoute] string slug, CancellationToken cancellationToken) + { + var key = await mediator.Send(new RevealExternalTriggerKeyCommand { Slug = slug }, cancellationToken).ConfigureAwait(false); + return Ok(new { key }); + } + + [HttpPost("{slug}/rotate-key")] + public async Task Rotate([FromRoute] string slug, CancellationToken cancellationToken) + { + var key = await mediator.Send(new RotateExternalTriggerKeyCommand { Slug = slug }, cancellationToken).ConfigureAwait(false); + return Ok(new { key }); + } + + [HttpPost("{slug}/rename")] + public async Task Rename([FromRoute] string slug, [FromBody] RenameExternalTriggerCommand command, CancellationToken cancellationToken) + { + await mediator.Send(command with { Slug = slug }, cancellationToken).ConfigureAwait(false); + return NoContent(); + } +} diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs new file mode 100644 index 000000000..6299a2b01 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -0,0 +1,51 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Commands.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Handlers.CommandHandlers.Workflows; + +public sealed class RevealExternalTriggerKeyCommandHandler( + IExternalTriggerStore store, CodeSpaceDbContext db, ExternalTriggerKeySealer sealer, + ICurrentTeam team, ICurrentUser actor, ILogger logger) + : IRequestHandler +{ + public async Task Handle(RevealExternalTriggerKeyCommand request, CancellationToken cancellationToken) + { + var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + var ciphertext = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == trigger.Id && t.TeamId == team.Id.Value && t.DeletedDate == null) + .Select(t => t.KeyCiphertext).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + var key = sealer.Open(ciphertext); + logger.LogWarning("External trigger {TriggerId} key revealed to user {UserId} in team {TeamId}", trigger.Id, actor.Id, team.Id); + return key; + } +} + +public sealed class RotateExternalTriggerKeyCommandHandler(IExternalTriggerStore store, ICurrentTeam team, ICurrentUser actor) + : IRequestHandler +{ + public async Task Handle(RotateExternalTriggerKeyCommand request, CancellationToken cancellationToken) + { + var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + return await store.RotateKeyAsync(trigger.Id, team.Id.Value, actor.Id!.Value, cancellationToken).ConfigureAwait(false); + } +} + +public sealed class RenameExternalTriggerCommandHandler(IExternalTriggerStore store, ICurrentTeam team, ICurrentUser actor) + : IRequestHandler +{ + public async Task Handle(RenameExternalTriggerCommand request, CancellationToken cancellationToken) + { + var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + await store.RenameAsync(trigger.Id, team.Id.Value, request.DesiredSlug, actor.Id!.Value, cancellationToken).ConfigureAwait(false); + return Unit.Value; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs new file mode 100644 index 000000000..debbdf4eb --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Messages.Dtos.Workflows; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerDefinitionSync(CodeSpaceDbContext db, IExternalTriggerStore store) : IScopedDependency +{ + public async Task SyncAsync(Guid workflowId, Guid teamId, WorkflowDefinition definition, Guid actorId, CancellationToken cancellationToken) + { + var nodes = definition.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToArray(); + var nodeIds = nodes.Select(n => n.Id).ToArray(); + var retired = await db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null && !nodeIds.Contains(t.NodeId)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var row in retired) row.DeletedDate = DateTimeOffset.UtcNow; + + // Release the partial unique slug before a replacement claims it. The command transaction keeps both atomic. + if (retired.Count != 0) await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + foreach (var node in nodes) + { + var slug = node.Config.ValueKind == JsonValueKind.Object && node.Config.TryGetProperty("slug", out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() : null; + await store.EnsureForNodeAsync(workflowId, teamId, node.Id, slug, actorId, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs new file mode 100644 index 000000000..33e2fdb25 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs @@ -0,0 +1,10 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerSlugConflictException(string slug, Exception? innerException = null) + : Exception($"External trigger slug '{slug}' is already in use. Choose another slug.", innerException), IFailure +{ + public FailureKind Kind => FailureKind.Conflict; + public string Code => FailureCodes.DuplicateResource; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs new file mode 100644 index 000000000..29d6cdc36 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -0,0 +1,82 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerStore(CodeSpaceDbContext db, ExternalTriggerKeySealer sealer) : IExternalTriggerStore +{ + public async Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken) => + await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Slug == slug && t.TeamId == teamId && t.DeletedDate == null) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.Slug)) + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + /// + /// The row owns the endpoint name and credential: a stale draft must not undo an explicit rename or rotation. + /// Node identity survives activation replacement on every save; an activation id would retire a working credential. + /// + public async Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken) + { + var slug = DeriveRequiredSlug(desiredSlug); + var row = await db.WorkflowExternalTrigger.SingleOrDefaultAsync( + t => t.WorkflowId == workflowId && t.TeamId == teamId && t.NodeId == nodeId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false); + if (row is not null) return View(row); + + if (!await db.Workflow.AnyAsync(w => w.Id == workflowId && w.TeamId == teamId && w.DeletedDate == null, cancellationToken).ConfigureAwait(false)) + throw new KeyNotFoundException("Workflow not found in this team."); + + var key = sealer.Mint(); + row = new WorkflowExternalTrigger + { + Id = Guid.NewGuid(), WorkflowId = workflowId, TeamId = teamId, NodeId = nodeId, Slug = slug, + KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = actorId, LastModifiedBy = actorId, + }; + db.WorkflowExternalTrigger.Add(row); + await SaveAsync(slug, cancellationToken).ConfigureAwait(false); + return View(row); + } + + public async Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken) + { + var row = await LoadAsync(triggerId, teamId, cancellationToken).ConfigureAwait(false); + var key = sealer.Mint(); + row.KeyDigest = key.Digest; + row.KeyCiphertext = key.Ciphertext; + row.LastModifiedBy = actorId; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return key.Key; + } + + public async Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken) + { + var row = await LoadAsync(triggerId, teamId, cancellationToken).ConfigureAwait(false); + row.Slug = DeriveRequiredSlug(desiredSlug); + row.LastModifiedBy = actorId; + await SaveAsync(row.Slug, cancellationToken).ConfigureAwait(false); + } + + private async Task LoadAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken) => + await db.WorkflowExternalTrigger.SingleOrDefaultAsync(t => t.Id == triggerId && t.TeamId == teamId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + + private async Task SaveAsync(string slug, CancellationToken cancellationToken) + { + try { await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "uq_wet_slug" }) + { + throw new ExternalTriggerSlugConflictException(slug, ex); + } + } + + private static string DeriveRequiredSlug(string? desiredSlug) + { + var slug = ExternalTriggerSlug.Derive(desiredSlug); + if (!ExternalTriggerSlug.IsUsable(slug)) + throw new ArgumentException("External trigger slug must contain ASCII letters or digits. Choose a usable ASCII name.", nameof(desiredSlug)); + return slug; + } + + private static ExternalTriggerView View(WorkflowExternalTrigger row) => new(row.Id, row.TeamId, row.WorkflowId, row.NodeId, row.Slug); +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs new file mode 100644 index 000000000..6d688d82d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs @@ -0,0 +1,13 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public interface IExternalTriggerStore : IScopedDependency +{ + Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken); + Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken); + Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken); + Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken); +} + +public sealed record ExternalTriggerView(Guid Id, Guid TeamId, Guid WorkflowId, string NodeId, string Slug); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 18e3744ac..942d311ca 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -10,6 +10,7 @@ using CodeSpace.Core.Services.Slugs; using CodeSpace.Core.Services.Workflows.Dispatch; using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.Core.Services.Workflows.Nodes; using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.Core.Services.Workflows.RunSources.Matchers; @@ -50,6 +51,7 @@ public sealed class WorkflowService : IWorkflowService, IScopedDependency private readonly Rerun.IRerunCellSeeder _cellSeeder; private readonly Engine.IRunCancellationRegistry _cancellationRegistry; private readonly ILogger _logger; + private readonly ExternalTriggerDefinitionSync _externalTriggers; /// Reason stamped on a branch agent run aborted by the kill-wave when an operator cancels its parent workflow run. private const string OperatorCancelledAgentReason = "Cancelled because its parent workflow run was cancelled by an operator."; @@ -63,7 +65,7 @@ public sealed class WorkflowService : IWorkflowService, IScopedDependency /// What someone answering the question of a run that ended — its card or its Room decision — is told. The run's end closed the wait unanswered (Discarded): an operator's stop, or the engine's own cleanup when the run failed. Continue re-runs the step, which asks again: telling them the question was "already resolved" would say someone answered it. public const string EndedRunQuestionMessage = "This run ended (it was stopped or failed), so its question closed unanswered. It opens again when the run is continued."; - public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definition, WorkflowLaunchServices launch, WorkflowControlServices control, ILogger logger) + public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definition, WorkflowLaunchServices launch, WorkflowControlServices control, ILogger logger, ExternalTriggerDefinitionSync externalTriggers) { _db = db; _validator = definition.Validator; @@ -81,6 +83,7 @@ public WorkflowService(CodeSpaceDbContext db, WorkflowDefinitionServices definit _cellSeeder = control.Cells; _cancellationRegistry = control.Cancellation; _logger = logger; + _externalTriggers = externalTriggers; } public async Task> ListAsync(Guid teamId, CancellationToken cancellationToken) @@ -213,6 +216,8 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio throw new InvalidOperationException($"A workflow with slug '{slug}' was created concurrently in this team. Please retry.", ex); } + await _externalTriggers.SyncAsync(workflowId, teamId, definition, _author.Id ?? Guid.Empty, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( "Workflow created. WorkflowId={WorkflowId} TeamId={TeamId} Slug={Slug} Name={Name} Nodes={NodeCount} Edges={EdgeCount} Activations={ActivationCount} Enabled={Enabled}", workflowId, teamId, slug, name, definition.Nodes.Count, definition.Edges.Count, activations.Count, enabled); @@ -253,6 +258,8 @@ public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? await ReplaceActivationsAsync(workflowId, activations, cancellationToken).ConfigureAwait(false); + await _externalTriggers.SyncAsync(workflowId, teamId, definition, _author.Id ?? Guid.Empty, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs new file mode 100644 index 000000000..420cfbbd9 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs @@ -0,0 +1,14 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; +using MediatR; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// Retires the URL configured by existing callers, so renaming must be explicit rather than a draft-save side effect. +public sealed record RenameExternalTriggerCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.WorkflowsWrite; + public string Slug { get; init; } = string.Empty; + public required string DesiredSlug { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs new file mode 100644 index 000000000..e91919a83 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs @@ -0,0 +1,12 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// Exposes an anonymous-execution bearer credential, so membership alone must not grant Viewers access. +public sealed record RevealExternalTriggerKeyCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.WorkflowsWrite; + public string Slug { get; init; } = string.Empty; +} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs new file mode 100644 index 000000000..114a263a0 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs @@ -0,0 +1,12 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// Immediately invalidates callers' existing credentials without a grace period; ordinary saves must never do this. +public sealed record RotateExternalTriggerKeyCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.WorkflowsWrite; + public string Slug { get; init; } = string.Empty; +} diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index c14083384..51192c867 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -21,6 +21,64 @@ public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : private const string BODY = """{"service":"a"}"""; private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, string Slug, string Key); + [Fact] + public async Task Management_reveals_rotates_and_renames_the_saved_endpoint() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + using var reveal = await harness.SendAsync(HttpMethod.Get, $"/api/workflows/external-triggers/{seed.Slug}/key", seed.UserId, seed.TeamId); + reveal.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(reveal)); + (await reveal.Content.ReadFromJsonAsync()).GetProperty("key").GetString().ShouldBe(seed.Key); + using var rotate = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.Slug}/rotate-key", seed.UserId, seed.TeamId); + rotate.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(rotate)); + var fresh = (await rotate.Content.ReadFromJsonAsync()).GetProperty("key").GetString(); + fresh.ShouldNotBeNullOrEmpty(); + fresh.ShouldNotBe(seed.Key); + using var oldKey = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + oldKey.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var renamed = "prod-alerts-v2-" + Guid.NewGuid().ToString("N"); + using var rename = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.Slug}/rename", seed.UserId, seed.TeamId, new { desiredSlug = renamed }); + rename.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(rename)); + using var oldUrl = await PostAsync(Url(seed), BODY, apiKey: fresh); + oldUrl.StatusCode.ShouldBe(HttpStatusCode.NotFound); + using var newUrl = await PostAsync($"/api/ext/{renamed}/run", BODY, apiKey: fresh); + newUrl.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + [Theory] + [InlineData("key", "GET")] + [InlineData("rotate-key", "POST")] + [InlineData("rename", "POST")] + public async Task Management_is_team_scoped_and_denies_viewers(string action, string method) + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + var viewer = await harness.AccountAsync(); + await harness.AddMemberAsync(seed.TeamId, viewer, TeamRole.Viewer); + var url = $"/api/workflows/external-triggers/{seed.Slug}/{action}"; + using var denied = await harness.SendAsync(new HttpMethod(method), url, viewer, seed.TeamId, method == "POST" ? new { desiredSlug = "unused" } : null); + denied.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + var other = await harness.TeamAsync((seed.UserId, TeamRole.Member)); + using var absent = await harness.SendAsync(new HttpMethod(method), url, seed.UserId, other, method == "POST" ? new { desiredSlug = "unused" } : null); + absent.StatusCode.ShouldBe(HttpStatusCode.NotFound); + using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers/{seed.Slug}/key"); + anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task A_slug_conflict_answers_409_without_breaking_either_endpoint() + { + var first = await SeedAsync(); + var other = await SeedAsync(); + using var conflict = await new TenantHarness(factory).SendAsync(HttpMethod.Post, + $"/api/workflows/external-triggers/{other.Slug}/rename", other.UserId, other.TeamId, new { desiredSlug = first.Slug }); + conflict.StatusCode.ShouldBe(HttpStatusCode.Conflict, await TenantHarness.DescribeAsync(conflict)); + using var firstCall = await PostAsync(Url(first), BODY, apiKey: first.Key); + using var otherCall = await PostAsync(Url(other), BODY, apiKey: other.Key); + firstCall.StatusCode.ShouldBe(HttpStatusCode.Accepted); + otherCall.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + [Fact] public async Task An_unknown_slug_is_404() { @@ -215,14 +273,9 @@ private async Task SeedCredentialAsync(Guid userId, Guid teamId, Guid work { using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var key = scope.ServiceProvider.GetRequiredService().Mint(); - db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger - { - Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "trigger", Slug = slug, - KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId, - }); - await db.SaveChangesAsync(); - return new(userId, teamId, workflowId, slug, key.Key); + var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null); + var key = scope.ServiceProvider.GetRequiredService().Open(row.KeyCiphertext); + return new(userId, teamId, workflowId, slug, key); } private Task RunsAsync(Seed seed) => new TenantHarness(factory).DbAsync(db => db.WorkflowRun.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).Select(r => r.Id).ToArrayAsync()); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs new file mode 100644 index 000000000..451ae07f0 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -0,0 +1,273 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Failures; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerManagementFlowTests(PostgresFixture fixture) +{ + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, string Slug, string Key); + + [Theory] + [InlineData("label")] + [InlineData("position")] + [InlineData("schema")] + [InlineData("slug")] + public async Task Saving_the_workflow_again_leaves_the_key_and_endpoint_working(string edit) + { + var seed = await SeedAsync(); + var before = await RowAsync(seed); + var definition = Definition(seed.Slug); + definition = definition with + { + Nodes = definition.Nodes.Select(n => edit switch + { + "label" when n.Id == "end" => n with { Label = "Renamed" }, + "position" when n.Id == "start" => n with { Position = new NodePosition { X = 400, Y = 250 } }, + "schema" when n.Id == "start" => n with { Config = Config(seed.Slug, "integer") }, + "slug" when n.Id == "start" => n with { Config = Config("different-draft") }, + _ => n, + }).ToArray(), + }; + await UpdateAsync(seed, definition); + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + after.Slug.ShouldBe(seed.Slug); + (await ReceiveAsync(seed, body: edit == "schema" ? "{\"service\":7}" : "{\"service\":\"api\"}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + if (edit == "schema") + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var activations = await db.WorkflowActivation.Where(a => a.WorkflowId == seed.WorkflowId).ToArrayAsync(); + activations.Count(a => a.DeletedDate == null).ShouldBe(1); + activations.Count(a => a.DeletedDate != null).ShouldBe(1); + var run = await db.WorkflowRun.Include(r => r.RunRequest).SingleAsync(r => r.WorkflowId == seed.WorkflowId); + run.RunRequest.ActivationId.ShouldBe(activations.Single(a => a.DeletedDate == null).Id); + } + + [Fact] + public async Task Reveal_hands_back_the_minted_key() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug })).ShouldBe(seed.Key); + } + + [Theory] + [InlineData(TeamRole.Viewer, "reveal")] + [InlineData(TeamRole.Viewer, "rotate")] + [InlineData(TeamRole.Viewer, "rename")] + [InlineData(TeamRole.Member, "reveal")] + [InlineData(TeamRole.Member, "rotate")] + [InlineData(TeamRole.Member, "rename")] + [InlineData(TeamRole.Admin, "reveal")] + public async Task Management_requires_workflows_write(TeamRole role, string operation) + { + var seed = await SeedAsync(); + using (var setup = fixture.BeginScope()) + { + var db = setup.Resolve(); + (await db.TeamMembership.SingleAsync(m => m.TeamId == seed.TeamId)).Role = role; + await db.SaveChangesAsync(); + } + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var error = await Record.ExceptionAsync(() => ManageAsync(scope.Resolve(), operation, seed.Slug, "renamed-" + Guid.NewGuid().ToString("N"))); + if (role == TeamRole.Viewer) error.ShouldBeOfType(); + else error.ShouldBeNull(); + } + + [Fact] + public async Task Rotating_invalidates_the_old_key_immediately_and_reveal_returns_the_new_key() + { + var seed = await SeedAsync(); + string fresh; + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + fresh = await scope.Resolve().Send(new RotateExternalTriggerKeyCommand { Slug = seed.Slug }); + fresh.ShouldNotBe(seed.Key); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await ReceiveAsync(seed with { Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + await UpdateAsync(seed, Definition(seed.Slug)); + using var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await verify.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug })).ShouldBe(fresh); + } + + [Fact] + public async Task Renaming_moves_the_endpoint_and_a_stale_draft_save_does_not_move_it_back() + { + var seed = await SeedAsync(); + var renamed = "prod-alerts-v2-" + Guid.NewGuid().ToString("N"); + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + await scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = renamed }); + await UpdateAsync(seed, Definition(seed.Slug)); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await ReceiveAsync(seed with { Slug = renamed })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await RowAsync(seed)).KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + } + + [Fact] + public async Task Renaming_onto_another_teams_live_slug_is_refused_and_original_is_untouched() + { + var first = await SeedAsync(); + var other = await SeedAsync(); + using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); + var error = await Record.ExceptionAsync(() => scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = other.Slug, DesiredSlug = first.Slug })); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Conflict); + (await ReceiveAsync(other)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Theory] + [InlineData("reveal")] + [InlineData("rotate")] + [InlineData("rename")] + public async Task Another_teams_slug_is_not_found(string operation) + { + var first = await SeedAsync(); + var other = await SeedAsync(); + using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); + await Should.ThrowAsync(() => ManageAsync(scope.Resolve(), operation, first.Slug, "unused")); + (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Theory] + [InlineData("!!!")] + [InlineData("告警")] + [InlineData("")] + public async Task An_underivable_slug_is_refused_without_creating_a_workflow(string desired) + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var error = await Record.ExceptionAsync(() => CreateAsync(scope, Definition(desired))); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBeOneOf(FailureKind.Invalid, FailureKind.Unprocessable); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + } + + [Fact] + public async Task An_underivable_rename_is_refused_and_keeps_the_old_endpoint() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + await Should.ThrowAsync(() => scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = "!!!" })); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Fact] + public async Task Creating_with_an_existing_slug_is_a_conflict_not_a_suffixed_endpoint() + { + var seed = await SeedAsync(); + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var error = await Record.ExceptionAsync(() => CreateAsync(scope, Definition(seed.Slug))); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Conflict); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Removing_or_replacing_the_node_retires_its_old_credential(bool replace) + { + var seed = await SeedAsync(); + var definition = Definition(seed.Slug); + definition = definition with + { + Nodes = definition.Nodes.Select(n => n.Id != "start" ? n : replace + ? n with { Id = "replacement" } + : n with { TypeKey = "trigger.manual", Config = WorkflowsTestSeed.EmptyJson() }).ToArray(), + Edges = [new EdgeDefinition { From = replace ? "replacement" : "start", To = "end" }], + }; + await UpdateAsync(seed, definition); + (await ReceiveAsync(seed)).Kind.ShouldBe(replace ? ExternalTriggerOutcomeKind.Unauthorized : ExternalTriggerOutcomeKind.NotFound); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var rows = await scope.Resolve().WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId).ToArrayAsync(); + rows.Single(t => t.NodeId == "start").DeletedDate.ShouldNotBeNull(); + if (replace) + { + var fresh = await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug }); + fresh.ShouldNotBe(seed.Key); + (await ReceiveAsync(seed with { Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + } + + private static JsonElement Config(string slug, string type = "string") => JsonSerializer.SerializeToElement(new + { + slug, + inputSchema = new { type = "object", properties = new { service = new { type } }, required = new[] { "service" } }, + }); + + private static WorkflowDefinition Definition(string slug) => WorkflowsTestSeed.MinimalDefinition() with + { + Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = Config(slug), Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }], + }; + + private static WorkflowActivationInput[] Activations(WorkflowDefinition definition) => definition.Nodes + .Where(n => n.TypeKey == "trigger.external") + .Select(n => new WorkflowActivationInput { TypeKey = n.TypeKey, Config = n.Config }).ToArray(); + + private static Task CreateAsync(ILifetimeScope scope, WorkflowDefinition definition) => scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "External management", Definition = definition, Activations = Activations(definition), + }); + + private async Task SeedAsync() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + var slug = "alerts-" + Guid.NewGuid().ToString("N"); + using var scope = fixture.BeginScopeAs(userId, teamId); + var workflowId = await CreateAsync(scope, Definition(slug)); + var row = await scope.Resolve().WorkflowExternalTrigger.SingleAsync(t => t.WorkflowId == workflowId && t.DeletedDate == null); + row.NodeId.ShouldBe("start"); + row.Slug.ShouldBe(slug); + return new(teamId, userId, workflowId, slug, scope.Resolve().Open(row.KeyCiphertext)); + } + + private async Task UpdateAsync(Seed seed, WorkflowDefinition definition) + { + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + await scope.Resolve().Send(new UpdateWorkflowCommand { WorkflowId = seed.WorkflowId, Name = "Edited", Definition = definition, Activations = Activations(definition) }); + } + + private async Task RowAsync(Seed seed) + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId && t.DeletedDate == null); + } + + private async Task ReceiveAsync(Seed seed, string body = "{\"service\":\"api\"}") + { + using var scope = fixture.BeginScope(); + return await scope.Resolve().ReceiveAsync(new(seed.Slug, seed.Key, null, body, false), CancellationToken.None); + } + + private static Task ManageAsync(IMediator mediator, string operation, string slug, string desired) => operation switch + { + "reveal" => mediator.Send(new RevealExternalTriggerKeyCommand { Slug = slug }), + "rotate" => mediator.Send(new RotateExternalTriggerKeyCommand { Slug = slug }), + _ => mediator.Send(new RenameExternalTriggerCommand { Slug = slug, DesiredSlug = desired }), + }; +} From 837e00a84dafe1953783d60f54e170acabec5e0c Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:17:40 +0800 Subject: [PATCH 10/26] fix: clarify external trigger slug errors and cover refused draft saves --- .../ExternalTriggers/ExternalTriggerStore.cs | 8 +++--- .../ExternalTriggerManagementFlowTests.cs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs index 29d6cdc36..590bc1adf 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -19,7 +19,7 @@ await db.WorkflowExternalTrigger.AsNoTracking() /// public async Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken) { - var slug = DeriveRequiredSlug(desiredSlug); + var slug = DeriveRequiredSlug(desiredSlug, "slug"); var row = await db.WorkflowExternalTrigger.SingleOrDefaultAsync( t => t.WorkflowId == workflowId && t.TeamId == teamId && t.NodeId == nodeId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false); if (row is not null) return View(row); @@ -52,7 +52,7 @@ public async Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actor public async Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken) { var row = await LoadAsync(triggerId, teamId, cancellationToken).ConfigureAwait(false); - row.Slug = DeriveRequiredSlug(desiredSlug); + row.Slug = DeriveRequiredSlug(desiredSlug, nameof(desiredSlug)); row.LastModifiedBy = actorId; await SaveAsync(row.Slug, cancellationToken).ConfigureAwait(false); } @@ -70,11 +70,11 @@ private async Task SaveAsync(string slug, CancellationToken cancellationToken) } } - private static string DeriveRequiredSlug(string? desiredSlug) + private static string DeriveRequiredSlug(string? desiredSlug, string parameterName) { var slug = ExternalTriggerSlug.Derive(desiredSlug); if (!ExternalTriggerSlug.IsUsable(slug)) - throw new ArgumentException("External trigger slug must contain ASCII letters or digits. Choose a usable ASCII name.", nameof(desiredSlug)); + throw new ArgumentException("The name you entered must produce a non-empty external trigger slug. ASCII letters and digits are supported. Hyphens and underscores are allowed. Choose a usable ASCII name.", parameterName); return slug; } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index 451ae07f0..2fd42c27c 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -63,6 +63,32 @@ public async Task Saving_the_workflow_again_leaves_the_key_and_endpoint_working( run.RunRequest.ActivationId.ShouldBe(activations.Single(a => a.DeletedDate == null).Id); } + [Fact] + public async Task An_unusable_draft_is_refused_without_damaging_the_live_endpoint() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var before = await RowAsync(seed); + + var error = await Should.ThrowAsync(() => UpdateAsync(seed, Definition("告警"))); + + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.Slug.ShouldBe(before.Slug); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + var outcome = await ReceiveAsync(seed); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + outcome.RunId.ShouldNotBe(Guid.Empty); + using var scope = fixture.BeginScope(); + var run = await scope.Resolve().WorkflowRun.SingleAsync(r => r.Id == outcome.RunId); + run.WorkflowId.ShouldBe(seed.WorkflowId); + + error.ParamName.ShouldBe("slug"); + error.Message.ShouldContain("slug"); + error.Message.ShouldContain("Hyphens and underscores are allowed"); + } + [Fact] public async Task Reveal_hands_back_the_minted_key() { From 439d8042bb4f95717787b5666d993c93b6c32bd1 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:28:22 +0800 Subject: [PATCH 11/26] fix: align external trigger slug guidance with derivation rules --- .../Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs index 590bc1adf..e33e31ddd 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -74,7 +74,7 @@ private static string DeriveRequiredSlug(string? desiredSlug, string parameterNa { var slug = ExternalTriggerSlug.Derive(desiredSlug); if (!ExternalTriggerSlug.IsUsable(slug)) - throw new ArgumentException("The name you entered must produce a non-empty external trigger slug. ASCII letters and digits are supported. Hyphens and underscores are allowed. Choose a usable ASCII name.", parameterName); + throw new ArgumentException("The name you entered must derive into a non-empty external trigger slug. Include at least one ASCII letter, digit or underscore. Hyphens and underscores are allowed in the resulting slug.", parameterName); return slug; } From 0897a9ab0637b037c74a968db30bb508ca74a607 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:40:23 +0800 Subject: [PATCH 12/26] feat: project the external trigger input contract onto workflow inputs --- .../ExternalTriggerInputProjection.cs | 45 +++++++++ .../Services/Workflows/WorkflowService.cs | 2 + .../ExternalTriggerManagementFlowTests.cs | 34 +++++++ .../ExternalTriggerInputProjectionTests.cs | 98 +++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs new file mode 100644 index 000000000..fa992672b --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using CodeSpace.Messages.Dtos.Workflows; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// Enforces inputSchema as the single input declaration so ingress validation and {{input.*}} cannot drift. +/// Apply before definition validation, serialization and hashing to freeze the same contract the engine resolves. +/// +public static class ExternalTriggerInputProjection +{ + public static IReadOnlyList Apply(IReadOnlyList declared, WorkflowDefinition definition) + { + var node = definition.Nodes.FirstOrDefault(n => n.TypeKey == ExternalTriggerSlug.TypeKey); + if (node is null) return declared; + + if (node.Config.ValueKind != JsonValueKind.Object + || !node.Config.TryGetProperty("inputSchema", out var schema) || schema.ValueKind != JsonValueKind.Object + || !schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object) + return Array.Empty(); + + var required = schema.TryGetProperty("required", out var names) && names.ValueKind == JsonValueKind.Array + ? names.EnumerateArray().Where(n => n.ValueKind == JsonValueKind.String).Select(n => n.GetString()!).ToHashSet(StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + + var inputs = new List(); + foreach (var property in properties.EnumerateObject()) + { + var value = property.Value; + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty("type", out _)) continue; + + inputs.Add(new WorkflowVariable + { + Name = property.Name, + Label = value.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String ? title.GetString() : null, + Description = value.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String ? description.GetString() : null, + Schema = value.Clone(), + Default = value.TryGetProperty("default", out var fallback) ? fallback.Clone() : null, + Required = required.Contains(property.Name), + }); + } + + return inputs; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 942d311ca..db4bee9f3 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -167,6 +167,7 @@ public async Task> ListAsync(Guid teamId, Cancell public async Task CreateAsync(Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, bool enabled, CancellationToken cancellationToken) { + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); @@ -227,6 +228,7 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, CancellationToken cancellationToken) { + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index 2fd42c27c..f3b9f96c1 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -24,6 +24,40 @@ public sealed class ExternalTriggerManagementFlowTests(PostgresFixture fixture) { private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, string Slug, string Key); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Saving_projects_the_node_contract_into_the_hashed_version_inputs(bool update) + { + var seed = await SeedAsync(); + if (update) + { + var definition = Definition(seed.Slug) with + { + Inputs = [new WorkflowVariable { Name = "stale", Schema = WorkflowsTestSeed.Json("""{"type":"string"}""") }], + }; + definition = definition with + { + Nodes = definition.Nodes.Select(n => n.Id == "start" ? n with { Config = Config(seed.Slug, "integer") } : n).ToArray(), + }; + await UpdateAsync(seed, definition); + } + + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var version = await db.WorkflowVersion.AsNoTracking().SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == (update ? 2 : 1)); + using var json = JsonDocument.Parse(version.DefinitionJson); + var inputs = json.RootElement.GetProperty("inputs"); + inputs.GetArrayLength().ShouldBe(1); + inputs[0].GetProperty("name").GetString().ShouldBe("service"); + inputs[0].GetProperty("required").GetBoolean().ShouldBeTrue(); + inputs[0].GetProperty("schema").GetProperty("type").GetString().ShouldBe(update ? "integer" : "string"); + var saved = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + version.DefinitionHash.ShouldBe(DefinitionHash.Compute(saved)); + var workflow = await db.Workflow.AsNoTracking().SingleAsync(w => w.Id == seed.WorkflowId); + workflow.DefinitionJson.ShouldBe(version.DefinitionJson); + } + [Theory] [InlineData("label")] [InlineData("position")] diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs new file mode 100644 index 000000000..1101d9d43 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Dtos.Workflows; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerInputProjectionTests +{ + [Fact] + public void The_declared_schema_becomes_the_workflow_inputs() + { + var inputs = ExternalTriggerInputProjection.Apply([], DefinitionWith(""" + { "slug": "a", "inputSchema": { + "type": "object", + "properties": { + "service": { "type": "string", "title": "Service", "description": "Which service" }, + "count": { "type": "integer", "default": 1 } + }, + "required": ["service"] } } + """)); + + inputs.Select(i => i.Name).ShouldBe(["service", "count"]); + var service = inputs[0]; + service.Label.ShouldBe("Service"); + service.Description.ShouldBe("Which service"); + service.Required.ShouldBeTrue(); + service.Default.ShouldBeNull(); + service.Schema.GetProperty("type").GetString().ShouldBe("string"); + service.Schema.GetProperty("title").GetString().ShouldBe("Service"); + service.Schema.GetProperty("description").GetString().ShouldBe("Which service"); + inputs[1].Required.ShouldBeFalse(); + inputs[1].Label.ShouldBeNull(); + inputs[1].Description.ShouldBeNull(); + inputs[1].Default!.Value.GetInt32().ShouldBe(1); + } + + [Fact] + public void A_workflow_without_an_external_node_keeps_its_inputs_as_declared() + { + var declared = new[] { new WorkflowVariable { Name = "x", Schema = Json("""{"type":"string"}""") } }; + var definition = new WorkflowDefinition { Nodes = [], Edges = [] }; + + ExternalTriggerInputProjection.Apply(declared, definition).ShouldBeSameAs(declared); + } + + [Theory] + [InlineData("""{ "slug": "a" }""")] + [InlineData("""{ "inputSchema": null }""")] + [InlineData("""{ "inputSchema": [] }""")] + [InlineData("""{ "inputSchema": { "type": "object" } }""")] + [InlineData("""{ "inputSchema": { "properties": [] } }""")] + public void A_node_without_object_properties_declares_nothing(string config) + { + var declared = new[] { new WorkflowVariable { Name = "stale", Schema = Json("""{"type":"string"}""") } }; + + ExternalTriggerInputProjection.Apply(declared, DefinitionWith(config)).ShouldBeEmpty(); + } + + [Fact] + public void A_property_with_no_type_is_skipped_not_fabricated() => + ExternalTriggerInputProjection.Apply([], DefinitionWith( + """{ "inputSchema": { "type": "object", "properties": { "x": { } } } }""")).ShouldBeEmpty(); + + [Fact] + public void The_node_schema_replaces_stale_inputs_and_preserves_the_full_property_schema() + { + var declared = new[] { new WorkflowVariable { Name = "stale", Schema = Json("""{"type":"string"}""") } }; + var definition = DefinitionWith(""" + { "inputSchema": { "properties": { + "Service_ID": { "type": "array", "items": { "type": "string", "enum": ["api", "worker"] }, "default": ["api"] } + }, "required": ["service_id"] } } + """); + + var inputs = ExternalTriggerInputProjection.Apply(declared, definition); + + inputs.Count.ShouldBe(1); + inputs[0].Name.ShouldBe("Service_ID"); + inputs[0].Required.ShouldBeFalse(); + inputs[0].Schema.GetProperty("items").GetProperty("enum").EnumerateArray() + .Select(v => v.GetString()).ShouldBe(["api", "worker"]); + inputs[0].Default!.Value[0].GetString().ShouldBe("api"); + definition.Inputs.ShouldBeEmpty(); + } + + private static WorkflowDefinition DefinitionWith(string configJson) => new() + { + Nodes = [new NodeDefinition { Id = "ext", TypeKey = "trigger.external", Config = Json(configJson) }], + Edges = [], + }; + + private static JsonElement Json(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} From 0e540faccb8f8ccbf92a6518612dae0bc9e4e362 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:56:30 +0800 Subject: [PATCH 13/26] Add team-scoped live external trigger metadata endpoint --- .../WorkflowExternalTriggersController.cs | 8 ++ ...istWorkflowExternalTriggersQueryHandler.cs | 24 ++++++ .../ExternalTriggers/ExternalTriggerStore.cs | 7 ++ .../ExternalTriggers/IExternalTriggerStore.cs | 1 + .../WorkflowExternalTriggerSummary.cs | 8 ++ .../ListWorkflowExternalTriggersQuery.cs | 10 +++ .../ExternalTriggerEndpointE2ETests.cs | 47 ++++++++++++ .../ExternalTriggerManagementFlowTests.cs | 74 +++++++++++++++++++ 8 files changed, 179 insertions(+) create mode 100644 backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs create mode 100644 backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs create mode 100644 backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs diff --git a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs index aa371e0a8..b06e39b27 100644 --- a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs @@ -1,4 +1,5 @@ using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -8,6 +9,13 @@ namespace CodeSpace.Api.Controllers; [Route("api/workflows/external-triggers")] public sealed class WorkflowExternalTriggersController(IMediator mediator) : ControllerBase { + [HttpGet] + public async Task List([FromQuery] ListWorkflowExternalTriggersQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken).ConfigureAwait(false); + return result == null ? NotFound() : Ok(result); + } + [HttpGet("{slug}/key")] public async Task Reveal([FromRoute] string slug, CancellationToken cancellationToken) { diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs new file mode 100644 index 000000000..a95122c6e --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs @@ -0,0 +1,24 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Handlers.QueryHandlers.Workflows; + +public sealed class ListWorkflowExternalTriggersQueryHandler(CodeSpaceDbContext db, IExternalTriggerStore store, ICurrentTeam team) + : IRequestHandler +{ + public async Task Handle(ListWorkflowExternalTriggersQuery request, CancellationToken cancellationToken) + { + var teamId = team.Id!.Value; + if (!await db.Workflow.AnyAsync(w => w.Id == request.WorkflowId && w.TeamId == teamId && w.DeletedDate == null, cancellationToken).ConfigureAwait(false)) + return null; + + var triggers = await store.ListForWorkflowAsync(request.WorkflowId, teamId, cancellationToken).ConfigureAwait(false); + return new WorkflowExternalTriggersResult(triggers + .Select(t => new WorkflowExternalTriggerSummary(t.Id, t.WorkflowId, t.NodeId, t.Slug)).ToArray()); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs index e33e31ddd..d29a29199 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -7,6 +7,13 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public sealed class ExternalTriggerStore(CodeSpaceDbContext db, ExternalTriggerKeySealer sealer) : IExternalTriggerStore { + public async Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken) => + await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null) + .OrderBy(t => t.NodeId) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.Slug)) + .ToArrayAsync(cancellationToken).ConfigureAwait(false); + public async Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken) => await db.WorkflowExternalTrigger.AsNoTracking() .Where(t => t.Slug == slug && t.TeamId == teamId && t.DeletedDate == null) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs index 6d688d82d..f5be26731 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs @@ -5,6 +5,7 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public interface IExternalTriggerStore : IScopedDependency { Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken); + Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken); Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken); Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken); Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken); diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs new file mode 100644 index 000000000..13cc2eb1c --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs @@ -0,0 +1,8 @@ +namespace CodeSpace.Messages.Dtos.Workflows; + +/// +/// Credentials are absent because keys are obtainable only through the permission-gated reveal endpoint. +/// +public sealed record WorkflowExternalTriggerSummary(Guid Id, Guid WorkflowId, string NodeId, string Slug); + +public sealed record WorkflowExternalTriggersResult(IReadOnlyList Triggers); diff --git a/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs b/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs new file mode 100644 index 000000000..ee47d45ea --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Workflows/ListWorkflowExternalTriggersQuery.cs @@ -0,0 +1,10 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Workflows; + +public sealed record ListWorkflowExternalTriggersQuery : IQuery, IRequireTeamMembership +{ + public required Guid WorkflowId { get; init; } +} diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index 51192c867..637711750 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -21,6 +21,53 @@ public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : private const string BODY = """{"service":"a"}"""; private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, string Slug, string Key); + [Fact] + public async Task Metadata_lists_only_public_live_fields_and_allows_viewers() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + var viewer = await harness.AccountAsync(); + await harness.AddMemberAsync(seed.TeamId, viewer, TeamRole.Viewer); + using var response = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={seed.WorkflowId}", viewer, seed.TeamId); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.EnumerateObject().Select(p => p.Name).ShouldBe(new[] { "triggers" }); + var trigger = payload.GetProperty("triggers").EnumerateArray().ShouldHaveSingleItem(); + trigger.EnumerateObject().Select(p => p.Name).Order().ShouldBe(new[] { "id", "nodeId", "slug", "workflowId" }); + trigger.GetProperty("id").GetGuid().ShouldNotBe(Guid.Empty); + trigger.GetProperty("workflowId").GetGuid().ShouldBe(seed.WorkflowId); + trigger.GetProperty("nodeId").GetString().ShouldBe("trigger"); + trigger.GetProperty("slug").GetString().ShouldBe(seed.Slug); + } + + [Fact] + public async Task Metadata_returns_404_for_a_missing_workflow_and_401_for_anonymous_callers() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + using var missing = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={Guid.NewGuid()}", seed.UserId, seed.TeamId); + missing.StatusCode.ShouldBe(HttpStatusCode.NotFound); + using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers?workflowId={seed.WorkflowId}"); + anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Metadata_returns_an_empty_list_when_the_workflow_has_no_live_trigger() + { + var seed = await SeedAsync(); + var harness = new TenantHarness(factory); + await harness.DbAsync(db => db.WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow))); + using var response = await harness.SendAsync(HttpMethod.Get, + $"/api/workflows/external-triggers?workflowId={seed.WorkflowId}", seed.UserId, seed.TeamId); + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("triggers").EnumerateArray().ShouldBeEmpty(); + } + [Fact] public async Task Management_reveals_rotates_and_renames_the_saved_endpoint() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index f3b9f96c1..4d3227e4b 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -12,6 +12,7 @@ using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; using CodeSpace.Messages.Failures; +using CodeSpace.Messages.Queries.Workflows; using MediatR; using Microsoft.EntityFrameworkCore; using Shouldly; @@ -24,6 +25,79 @@ public sealed class ExternalTriggerManagementFlowTests(PostgresFixture fixture) { private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, string Slug, string Key); + [Fact] + public async Task Metadata_returns_the_live_rename_after_a_stale_draft_save() + { + var seed = await SeedAsync(); + var renamed = "live-" + Guid.NewGuid().ToString("N"); + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + await scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = renamed }); + await UpdateAsync(seed, Definition(seed.Slug)); + using var read = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var result = await read.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + var trigger = result!.Triggers.ShouldHaveSingleItem(); + trigger.Id.ShouldBe((await RowAsync(seed)).Id); + trigger.WorkflowId.ShouldBe(seed.WorkflowId); + trigger.NodeId.ShouldBe("start"); + trigger.Slug.ShouldBe(renamed); + } + + [Fact] + public async Task Metadata_does_not_return_another_teams_workflow() + { + var seed = await SeedAsync(); + var other = await SeedAsync(); + using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); + (await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId })).ShouldBeNull(); + var own = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = other.WorkflowId }); + own!.Triggers.ShouldHaveSingleItem().WorkflowId.ShouldBe(other.WorkflowId); + } + + [Fact] + public async Task Metadata_is_empty_for_a_workflow_that_never_had_an_external_trigger() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var workflowId = await CreateAsync(scope, WorkflowsTestSeed.MinimalDefinition()); + var result = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = workflowId }); + result.ShouldNotBeNull(); + result.Triggers.ShouldBeEmpty(); + } + + [Fact] + public async Task Metadata_lists_every_live_row_without_assuming_a_single_trigger() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + await scope.Resolve().EnsureForNodeAsync(seed.WorkflowId, seed.TeamId, + "second", "second-" + Guid.NewGuid().ToString("N"), seed.UserId, CancellationToken.None); + var result = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + result!.Triggers.Select(t => t.NodeId).Order().ShouldBe(new[] { "second", "start" }); + result.Triggers.ShouldAllBe(t => t.WorkflowId == seed.WorkflowId); + } + + [Fact] + public async Task Metadata_excludes_retired_nodes_and_deleted_workflows() + { + var seed = await SeedAsync(); + using (var setup = fixture.BeginScope()) + { + var db = setup.Resolve(); + await db.WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow)); + } + using (var read = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + { + var result = await read.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); + result!.Triggers.ShouldBeEmpty(); + } + using (var setup = fixture.BeginScope()) + await setup.Resolve().Workflow.Where(w => w.Id == seed.WorkflowId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.DeletedDate, DateTimeOffset.UtcNow)); + using var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + (await verify.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId })).ShouldBeNull(); + } + [Theory] [InlineData(false)] [InlineData(true)] From 27f84dd1c396b6c1ed6522d84308f16a924183a1 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:07:20 +0800 Subject: [PATCH 14/26] feat: external trigger node inspector with endpoint, key reveal, rotate and rename --- frontend/src/api/workflows.ts | 32 ++++ .../workflows/external-trigger-editor.tsx | 25 +++ .../external-trigger-inspector.test.tsx | 167 ++++++++++++++++++ .../workflows/external-trigger-inspector.tsx | 143 +++++++++++++++ frontend/src/hooks/use-workflows.ts | 25 +++ .../src/lib/external-trigger-slug.test.ts | 29 +++ frontend/src/lib/external-trigger-slug.ts | 10 ++ frontend/src/lib/external-trigger-url.ts | 3 + ...teamSlug.workflows.$workflowSlug.index.tsx | 3 + 9 files changed, 437 insertions(+) create mode 100644 frontend/src/components/workflows/external-trigger-editor.tsx create mode 100644 frontend/src/components/workflows/external-trigger-inspector.test.tsx create mode 100644 frontend/src/components/workflows/external-trigger-inspector.tsx create mode 100644 frontend/src/lib/external-trigger-slug.test.ts create mode 100644 frontend/src/lib/external-trigger-slug.ts create mode 100644 frontend/src/lib/external-trigger-url.ts diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 2b5e6e52b..cdfed56be 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -161,6 +161,21 @@ export interface WorkflowActivationInput { enabled: boolean; } +export interface WorkflowExternalTriggerSummary { + id: string; + workflowId: string; + nodeId: string; + slug: string; +} + +export interface WorkflowExternalTriggersResult { + triggers: WorkflowExternalTriggerSummary[]; +} + +export interface ExternalTriggerKeyResult { + key: string; +} + export interface CreateWorkflowInput { name: string; description?: string | null; @@ -1440,6 +1455,23 @@ function decodeRunDataCompleteness(value: unknown, expectedRunId: string): Workf export const workflowsApi = { list: () => fetchJson("/api/workflows"), + listExternalTriggers: (workflowId: string) => fetchJson( + `/api/workflows/external-triggers?${new URLSearchParams({ workflowId })}`, + ), + + revealExternalTriggerKey: (slug: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(slug)}/key`, + ), + + rotateExternalTriggerKey: (slug: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(slug)}/rotate-key`, { method: "POST" }, + ), + + renameExternalTrigger: (slug: string, desiredSlug: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(slug)}/rename`, + { method: "POST", body: JSON.stringify({ desiredSlug }) }, + ), + /** Resolve one workflow by ref — its GUID (legacy link) or team-unique slug (clean URL). */ get: (ref: string) => fetchJson(`/api/workflows/${encodeURIComponent(ref)}`), diff --git a/frontend/src/components/workflows/external-trigger-editor.tsx b/frontend/src/components/workflows/external-trigger-editor.tsx new file mode 100644 index 000000000..c329dcfba --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-editor.tsx @@ -0,0 +1,25 @@ +import { useWorkflowExternalTriggers } from "@/hooks/use-workflows"; + +import { ExternalTriggerInspector } from "./external-trigger-inspector"; + +interface IExternalTriggerEditorProps { + workflowId: string; + nodeId: string; + config: Record; + onConfigChange: (next: Record) => void; +} + +export function ExternalTriggerEditor({ workflowId, nodeId, config, onConfigChange }: IExternalTriggerEditorProps) { + const triggers = useWorkflowExternalTriggers(workflowId); + + if (triggers.isPending) return
Loading endpoint...
; + if (triggers.isError) return ( +
+

{triggers.error.message}

+ +
+ ); + + const currentSlug = triggers.data.triggers.find((trigger) => trigger.nodeId === nodeId)?.slug ?? null; + return ; +} diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx new file mode 100644 index 000000000..c9e5d119f --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -0,0 +1,167 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, renderHook, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ExternalTriggerInspector } from "./external-trigger-inspector"; +import { ExternalTriggerEditor } from "./external-trigger-editor"; +import { useUpdateWorkflow, useWorkflowExternalTriggers } from "@/hooks/use-workflows"; + +const REQUESTS = vi.fn(); +const CONFIG_CHANGE = vi.fn(); +const SUMMARY = { id: "trigger-1", workflowId: "workflow-1", nodeId: "ext", slug: "prod-alerts" }; + +function renderInspector(currentSlug: string | null = "prod-alerts", slug = "prod-alerts") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + function Inspector() { + const [config, setConfig] = useState>({ slug, inputSchema: {} }); + return { CONFIG_CHANGE(next); setConfig(next); }} />; + } + return render(); +} + +beforeEach(() => { + CONFIG_CHANGE.mockReset(); + REQUESTS.mockReset(); + REQUESTS.mockImplementation(async (_url, init) => new Response(init?.method === "POST" && String(_url).endsWith("/rename") ? null : JSON.stringify({ key: "ext_secret" }), { status: init?.method === "POST" && String(_url).endsWith("/rename") ? 204 : 200 })); + vi.stubGlobal("fetch", REQUESTS); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("ExternalTriggerInspector", () => { + it("shows the keyless canonical endpoint and only copies a key-bearing curl after reveal", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + renderInspector(); + expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + expect(screen.queryByDisplayValue(/^ext_/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /show key/i })); + expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/key"), expect.anything()); + fireEvent.click(screen.getByRole("button", { name: /copy key/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("ext_secret")); + fireEvent.click(screen.getByRole("button", { name: /copy curl/i })); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/prod-alerts/run?key=ext_secret"))); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST")); + }); + + it("requires confirmation before rotating and warns that the old key stops working immediately", async () => { + renderInspector(); + fireEvent.click(screen.getByRole("button", { name: /regenerate/i })); + const group = screen.getByRole("group", { name: /confirm.*regenerate/i }); + expect(within(group).getByText(/old key stops working immediately/i)).toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + fireEvent.click(within(group).getByRole("button", { name: /cancel/i })); + expect(REQUESTS).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /regenerate/i })); + fireEvent.click(within(screen.getByRole("group")).getByRole("button", { name: /confirm/i })); + expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/rotate-key"), expect.objectContaining({ method: "POST" })); + }); + + it("keeps live and draft names distinct and explicitly confirms rename using desiredSlug", async () => { + renderInspector("prod-alerts", "New Alerts"); + expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(screen.getByText(/saving.*does not rename/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^rename/i })); + const group = screen.getByRole("group", { name: /confirm.*rename/i }); + expect(within(group).getByText(/existing url will stop working/i)).toBeInTheDocument(); + expect(REQUESTS).not.toHaveBeenCalled(); + fireEvent.click(within(group).getByRole("button", { name: /confirm/i })); + await waitFor(() => expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/rename"), expect.objectContaining({ method: "POST", body: JSON.stringify({ desiredSlug: "new-alerts" }) }))); + }); + + it("refuses an all-CJK draft without a fallback and disables rename", () => { + renderInspector(); + fireEvent.change(screen.getByRole("textbox", { name: /draft slug/i }), { target: { value: "日本語" } }); + expect(screen.getByRole("alert")).toHaveTextContent(/use an english/i); + expect(screen.getByRole("button", { name: /^rename/i })).toBeDisabled(); + expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "日本語", inputSchema: {} }); + expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + }); + + it("shows a save-first state without any live credential actions", () => { + renderInspector(null); + expect(screen.getByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /show key|regenerate|rename/i })).not.toBeInTheDocument(); + expect(screen.queryByText("/api/ext/prod-alerts/run")).not.toBeInTheDocument(); + }); + + it("reports management errors without showing a key", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ code: "forbidden", message: "Permission denied" }), { status: 403 })); + renderInspector(); + fireEvent.click(screen.getByRole("button", { name: /show key/i })); + expect(await screen.findByRole("alert")).toHaveTextContent("Permission denied"); + expect(screen.queryByDisplayValue(/^ext_/)).not.toBeInTheDocument(); + }); + + it("uses the existing field modal to compile an input JSON Schema", async () => { + renderInspector(); + fireEvent.click(screen.getByTitle("Add field")); + fireEvent.change(screen.getByPlaceholderText("e.g. start_time"), { target: { value: "message" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith(expect.objectContaining({ inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] } }))); + }); + + it("preserves schema constraints and can clear the display name while editing", async () => { + const client = new QueryClient(); + const config = { slug: "prod-alerts", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", title: "Alert message", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } }; + render(); + fireEvent.click(screen.getByTitle("Edit field")); + fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith({ slug: "prod-alerts", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } })); + }); +}); + +describe("ExternalTriggerEditor live metadata", () => { + it("reads the live slug by nodeId, refreshes after rename, and never uses the draft as live identity", async () => { + let liveSlug = "prod-alerts"; + REQUESTS.mockImplementation(async (url) => { + if (String(url).endsWith("/rename")) { liveSlug = "draft"; return new Response(null, { status: 204 }); } + return new Response(JSON.stringify({ triggers: [{ ...SUMMARY, nodeId: "other", slug: "other" }, { ...SUMMARY, slug: liveSlug }] }), { status: 200 }); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/api/workflows/external-triggers?workflowId=workflow-1"), expect.anything()); + fireEvent.click(screen.getByRole("button", { name: /^rename/i })); + fireEvent.click(within(screen.getByRole("group")).getByRole("button", { name: /confirm/i })); + expect(await screen.findByText("/api/ext/draft/run")).toBeInTheDocument(); + }); + + it("treats an empty list as unsaved, not as a draft-backed endpoint", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [] }), { status: 200 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); + }); + + it("does not mislabel a list error as an unsaved endpoint and permits retry", async () => { + REQUESTS.mockResolvedValueOnce(new Response(JSON.stringify({ message: "Workflow not found" }), { status: 404 })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("Workflow not found"); + expect(screen.queryByText(/save the workflow/i)).not.toBeInTheDocument(); + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [SUMMARY] }), { status: 200 })); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(await screen.findByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + }); + + it("refreshes live metadata after saving a newly added node", async () => { + let saved = false; + REQUESTS.mockImplementation(async (_url, init) => { + if (init?.method === "PUT") { saved = true; return new Response(null, { status: 204 }); } + return new Response(JSON.stringify({ triggers: saved ? [SUMMARY] : [] }), { status: 200 }); + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => ({ list: useWorkflowExternalTriggers("workflow-1"), update: useUpdateWorkflow() }), { + wrapper: ({ children }) => {children}, + }); + await waitFor(() => expect(result.current.list.data).toEqual({ triggers: [] })); + await act(async () => result.current.update.mutateAsync({ workflowId: "workflow-1", input: { name: "Workflow", definition: { schemaVersion: 1, nodes: [], edges: [] }, activations: [] } })); + await waitFor(() => expect(result.current.list.data).toEqual({ triggers: [SUMMARY] })); + }); +}); diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx new file mode 100644 index 000000000..b06332016 --- /dev/null +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; + +import { Ic } from "@/_imported/ai-code-space/icons"; +import type { WorkflowVariable } from "@/api/workflows"; +import { useRenameExternalTrigger, useRevealExternalTriggerKey, useRotateExternalTriggerKey } from "@/hooks/use-workflows"; +import { deriveExternalTriggerSlug, isUsableExternalTriggerSlug } from "@/lib/external-trigger-slug"; +import { externalTriggerUrl } from "@/lib/external-trigger-url"; + +import { StartNodeInputsEditor } from "./StartNodeInputsEditor"; + +interface IExternalTriggerInspectorProps { + config: Record; + nodeId: string; + currentSlug: string | null; + onConfigChange: (next: Record) => void; +} + +function asObject(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfigChange }: IExternalTriggerInspectorProps) { + const reveal = useRevealExternalTriggerKey(); + const rotate = useRotateExternalTriggerKey(); + const rename = useRenameExternalTrigger(); + const [confirming, setConfirming] = useState<"rotate" | "rename" | null>(null); + const [key, setKey] = useState(null); + const [copyStatus, setCopyStatus] = useState(null); + const [copyError, setCopyError] = useState(null); + const draft = typeof config.slug === "string" ? config.slug : ""; + const derived = deriveExternalTriggerSlug(draft); + const usable = isUsableExternalTriggerSlug(derived); + const schema = asObject(config.inputSchema); + const properties = asObject(schema.properties); + const required = Array.isArray(schema.required) ? schema.required : []; + const fields: WorkflowVariable[] = Object.entries(properties).map(([name, value]) => { + const property = asObject(value); + return { name, schema: value, label: typeof property.title === "string" ? property.title : undefined, default: property.default, required: required.includes(name) }; + }); + const pending = reveal.isPending || rotate.isPending || rename.isPending; + const error = reveal.error ?? rotate.error ?? rename.error; + const endpoint = currentSlug ? `/api/ext/${encodeURIComponent(currentSlug)}/run` : null; + const fullUrl = endpoint ? externalTriggerUrl(endpoint) : ""; + const curl = key ? `curl -X POST '${fullUrl}?key=${encodeURIComponent(key)}' -H 'Content-Type: application/json' -d '{}'` : null; + + const changeFields = (next: WorkflowVariable[]) => { + const nextProperties = Object.fromEntries(next.map((field) => { + const property = { ...asObject(field.schema) }; + if (field.label != null) property.title = field.label; + else delete property.title; + if (field.default !== undefined) property.default = field.default; + else delete property.default; + return [field.name, property]; + })); + onConfigChange({ ...config, inputSchema: { ...schema, type: "object", properties: nextProperties, required: next.filter((field) => field.required).map((field) => field.name) } }); + }; + + const copy = async (text: string) => { + setCopyStatus(null); + setCopyError(null); + try { await navigator.clipboard.writeText(text); setCopyStatus("Copied"); } + catch { setCopyError("Could not copy to clipboard."); } + }; + + const requestKey = () => { + if (!currentSlug || pending) return; + reveal.reset(); + rotate.reset(); + rename.reset(); + reveal.mutate(currentSlug, { onSuccess: (result) => setKey(result.key) }); + }; + + const confirm = () => { + if (!currentSlug || pending) return; + reveal.reset(); + rotate.reset(); + rename.reset(); + if (confirming === "rotate") { + setKey(null); + rotate.mutate(currentSlug, { onSuccess: (result) => { setKey(result.key); setConfirming(null); } }); + } else if (confirming === "rename" && usable && derived !== currentSlug) { + rename.mutate({ slug: currentSlug, desiredSlug: derived }, { onSuccess: () => { setKey(null); setConfirming(null); } }); + } + }; + + return ( + <> +
+ +

+ {usable ? `Derived slug: ${derived}` : "Cannot derive a usable slug. Use an English name with ASCII letters, digits or underscores."} +

+
+ + + +
+

Endpoint

+ {!endpoint ?

Save the workflow to create this endpoint.

: ( + <> +
Live slug{currentSlug}
+ {endpoint} + + {draft !== currentSlug &&

The draft differs from the live slug. Saving the workflow does not rename this endpoint.

} +
+ + + +
+ {confirming && ( +
+

{confirming === "rotate" ? "The old key stops working immediately. Existing callers must use the new key." : `The existing URL will stop working. Rename the live endpoint to ${derived}?`}

+
+ + +
+
+ )} + {key && ( +
+ + +
+ )} + {curl && ( +
+
{curl}
+ +
+ )} + + )} + {pending &&

Working...

} + {error &&

{error.message}

} + {copyStatus &&

{copyStatus}

} + {copyError &&

{copyError}

} +
+ + ); +} diff --git a/frontend/src/hooks/use-workflows.ts b/frontend/src/hooks/use-workflows.ts index 7adb72e8e..154dcfbac 100644 --- a/frontend/src/hooks/use-workflows.ts +++ b/frontend/src/hooks/use-workflows.ts @@ -55,10 +55,35 @@ export function useUpdateWorkflow() { onSuccess: (_, { workflowId }) => { qc.invalidateQueries({ queryKey: ["workflows"] }); qc.invalidateQueries({ queryKey: ["workflow", workflowId] }); + qc.invalidateQueries({ queryKey: ["workflow-external-triggers", workflowId] }); }, }); } +export function useWorkflowExternalTriggers(workflowId: string) { + return useQuery({ + queryKey: ["workflow-external-triggers", workflowId], + queryFn: () => workflowsApi.listExternalTriggers(workflowId), + staleTime: 0, + }); +} + +export function useRevealExternalTriggerKey() { + return useMutation({ mutationFn: (slug: string) => workflowsApi.revealExternalTriggerKey(slug), gcTime: 0 }); +} + +export function useRotateExternalTriggerKey() { + return useMutation({ mutationFn: (slug: string) => workflowsApi.rotateExternalTriggerKey(slug), gcTime: 0 }); +} + +export function useRenameExternalTrigger() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ slug, desiredSlug }: { slug: string; desiredSlug: string }) => workflowsApi.renameExternalTrigger(slug, desiredSlug), + onSuccess: () => qc.invalidateQueries({ queryKey: ["workflow-external-triggers"] }), + }); +} + export function useDeleteWorkflow() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/lib/external-trigger-slug.test.ts b/frontend/src/lib/external-trigger-slug.test.ts new file mode 100644 index 000000000..c78ec86ba --- /dev/null +++ b/frontend/src/lib/external-trigger-slug.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { deriveExternalTriggerSlug, isUsableExternalTriggerSlug } from "./external-trigger-slug"; + +describe("external trigger slug parity with backend golden vectors", () => { + it.each([ + ["Prod-Alerts", "prod-alerts"], + ["日本語", ""], + ["prod 错误 报警", "prod"], + ["../../etc/passwd", "etc-passwd"], + [" Padded Name ", "padded-name"], + ["snake_case_kept", "snake_case_kept"], + ["café münch", "caf-m-nch"], + ["---spaces---", "spaces"], + ["$$$", ""], + ["a".repeat(63) + " b", "a".repeat(63)], + ["A".repeat(80), "a".repeat(64)], + ])("derives %s as %s", (input, expected) => { + expect(deriveExternalTriggerSlug(input)).toBe(expected); + }); + + it.each(["", " ", "UPPER", "日本語", "a/b", "a".repeat(65)])("rejects unusable slug %s", (slug) => { + expect(isUsableExternalTriggerSlug(slug)).toBe(false); + }); + + it.each(["prod-alerts", "snake_case", "0", "a".repeat(64)])("accepts %s", (slug) => { + expect(isUsableExternalTriggerSlug(slug)).toBe(true); + }); +}); diff --git a/frontend/src/lib/external-trigger-slug.ts b/frontend/src/lib/external-trigger-slug.ts new file mode 100644 index 000000000..fb3fc2757 --- /dev/null +++ b/frontend/src/lib/external-trigger-slug.ts @@ -0,0 +1,10 @@ +import { deriveSlug } from "@/components/agents/deriveSlug"; + +/** Shares the canonical ASCII slug derivation; empty names never receive a fallback. */ +export function deriveExternalTriggerSlug(name: string): string { + return deriveSlug(name); +} + +export function isUsableExternalTriggerSlug(slug: string): boolean { + return /^[a-z0-9_-]{1,64}$/.test(slug); +} diff --git a/frontend/src/lib/external-trigger-url.ts b/frontend/src/lib/external-trigger-url.ts new file mode 100644 index 000000000..950165de4 --- /dev/null +++ b/frontend/src/lib/external-trigger-url.ts @@ -0,0 +1,3 @@ +export function externalTriggerUrl(endpoint: string): string { + return new URL(endpoint, import.meta.env.VITE_API_URL || window.location.origin).href; +} diff --git a/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx b/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx index ad4415223..055e74d25 100644 --- a/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx +++ b/frontend/src/routes/_app.teams.$teamSlug.workflows.$workflowSlug.index.tsx @@ -44,6 +44,7 @@ import { PostMessageInputsEditor } from "@/components/workflows/PostMessageInput import { ERROR_HANDLE, errorRouteTarget, setErrorRoute } from "@/lib/workflowErrorRoute"; import { introspectScope } from "@/components/workflows/scope-introspection"; import { StartNodeInputsEditor } from "@/components/workflows/StartNodeInputsEditor"; +import { ExternalTriggerEditor } from "@/components/workflows/external-trigger-editor"; import { SubworkflowEditor } from "@/components/workflows/SubworkflowEditor"; import { LoopEditor } from "@/components/workflows/LoopEditor"; import { MapEditor } from "@/components/workflows/MapEditor"; @@ -1464,6 +1465,8 @@ function NodeInspector({ start node"). Its Config/Input schemas are empty, so there's nothing else to show. */} {manifest.isManual ? ( + ) : manifest.typeKey === "trigger.external" ? ( + ) : manifest.typeKey === "flow.subworkflow" ? ( // Sub-workflow gets a dedicated editor: pick the child workflow, then map onto ITS declared // inputs (rendered via SchemaForm) — far better than the generic "workflowId + raw object". From 7768ca7f34335811e5be332fa6f376b640f47bb7 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:13:54 +0800 Subject: [PATCH 15/26] fix: preserve non-object external trigger field schemas --- .../external-trigger-inspector.test.tsx | 22 +++++++++++++++++++ .../workflows/external-trigger-inspector.tsx | 12 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx index c9e5d119f..72152ce59 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.test.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -30,6 +30,28 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); describe("ExternalTriggerInspector", () => { + it("preserves a boolean-false property when another property's label changes", () => { + const client = new QueryClient(); + const config = { slug: "prod-alerts", inputSchema: { properties: { blocked: false, message: { type: "string" } } } }; + render(); + fireEvent.click(screen.getByText("message")); + fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "Alert message" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "prod-alerts", inputSchema: { type: "object", properties: { blocked: false, message: { type: "string", title: "Alert message" } }, required: [] } }); + expect(CONFIG_CHANGE.mock.lastCall?.[0].inputSchema.properties.blocked).toBe(false); + }); + + it("preserves a boolean constraint and warns when its display label cannot be applied", () => { + const client = new QueryClient(); + const config = { slug: "prod-alerts", inputSchema: { properties: { blocked: false } } }; + render(); + fireEvent.click(screen.getByText("blocked")); + fireEvent.change(screen.getByPlaceholderText("blocked"), { target: { value: "Blocked field" } }); + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + expect(CONFIG_CHANGE.mock.lastCall?.[0].inputSchema.properties.blocked).toBe(false); + expect(screen.getByRole("alert")).toHaveTextContent(/blocked.*label.*default.*could not be applied.*constraint.*preserved/i); + }); + it("shows the keyless canonical endpoint and only copies a key-bearing curl after reveal", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx index b06332016..f66f1ec1e 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -27,6 +27,7 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig const [key, setKey] = useState(null); const [copyStatus, setCopyStatus] = useState(null); const [copyError, setCopyError] = useState(null); + const [schemaWarning, setSchemaWarning] = useState(null); const draft = typeof config.slug === "string" ? config.slug : ""; const derived = deriveExternalTriggerSlug(draft); const usable = isUsableExternalTriggerSlug(derived); @@ -44,7 +45,14 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig const curl = key ? `curl -X POST '${fullUrl}?key=${encodeURIComponent(key)}' -H 'Content-Type: application/json' -d '{}'` : null; const changeFields = (next: WorkflowVariable[]) => { + const unappliedMetadata: string[] = []; const nextProperties = Object.fromEntries(next.map((field) => { + const hasMetadata = field.label != null || field.default !== undefined; + if (field.schema === null || typeof field.schema !== "object" || Array.isArray(field.schema)) { + if (hasMetadata) unappliedMetadata.push(field.name); + return [field.name, field.schema]; + } + if (!hasMetadata && !("title" in field.schema) && !("default" in field.schema)) return [field.name, field.schema]; const property = { ...asObject(field.schema) }; if (field.label != null) property.title = field.label; else delete property.title; @@ -52,6 +60,9 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig else delete property.default; return [field.name, property]; })); + setSchemaWarning(unappliedMetadata.length > 0 + ? `${unappliedMetadata.join(", ")}: label/default could not be applied to a non-object schema. The original constraint was preserved.` + : null); onConfigChange({ ...config, inputSchema: { ...schema, type: "object", properties: nextProperties, required: next.filter((field) => field.required).map((field) => field.name) } }); }; @@ -96,6 +107,7 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig + {schemaWarning &&

{schemaWarning}

}

Endpoint

From 298cd523bc448fb309fe5f99c01129b029557dee Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:46:54 +0800 Subject: [PATCH 16/26] Route the external trigger ingress through the mediator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExternalTriggersController resolved IExternalTriggerIngress directly, so the anonymous delivery path reached the application without passing any MediatR pipeline behavior — the same bypass the run-ledger SSE stream made. Dispatch a marker-less ReceiveExternalTriggerCommand instead, mirroring the anonymous webhook ingress. The command declares no authorization marker, so no authorization behavior selects it, exactly like ReceiveWebhookCommand; the endpoint stays anonymous at [AllowAnonymous]. The outcome type lives in CodeSpace.Messages so the Messages assembly keeps no reference to CodeSpace.Core: ExternalTriggerDeliveryOutcome mirrors the ingress's own outcome, and the handler maps between the two. The HTTP contract is unchanged — the bounded body and its 413, the Bearer -> X-Api-Key -> ?key= precedence, the whitespace-only Idempotency-Key normalization, and the 404/401/400/200/202 outcome mapping all behave as before. ReceiveExternalTriggerCommand joins AnonymousByDesign in the authorization inventory, the same list ReceiveWebhookCommand already sits in. --- .../Controllers/ExternalTriggersController.cs | 26 ++++++++++++------ .../ExternalTriggerCommandHandlers.cs | 27 +++++++++++++++++++ .../ReceiveExternalTriggerCommand.cs | 27 +++++++++++++++++++ .../ExternalTriggerDeliveryOutcome.cs | 27 +++++++++++++++++++ .../RequestAuthorizationInventoryTests.cs | 3 ++- 5 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs create mode 100644 backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs index 488d4d58d..058f8212f 100644 --- a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -1,6 +1,9 @@ using CodeSpace.Api.Extensions; using CodeSpace.Api.Http; using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -20,9 +23,9 @@ namespace CodeSpace.Api.Controllers; [EnableRateLimiting(WebhookIngressRateLimitExtension.PolicyName)] public class ExternalTriggersController : ControllerBase { - private readonly IExternalTriggerIngress _ingress; + private readonly IMediator _mediator; - public ExternalTriggersController(IExternalTriggerIngress ingress) { _ingress = ingress; } + public ExternalTriggersController(IMediator mediator) { _mediator = mediator; } [HttpPost("{slug}/run")] [RequestSizeLimit(MaxDeliveryBodyBytes)] @@ -39,15 +42,22 @@ public async Task Run([FromRoute] string slug, [FromQuery] string var idempotencyHeader = Request.Headers["Idempotency-Key"].ToString(); var idempotencyKey = string.IsNullOrWhiteSpace(idempotencyHeader) ? null : idempotencyHeader; var idempotencyRejected = idempotencyKey != null && !ExternalTriggerIdempotency.IsWellFormed(idempotencyKey); - var outcome = await _ingress.ReceiveAsync(new ExternalTriggerCall(slug, presentedKey, idempotencyKey, body, idempotencyRejected), cancellationToken).ConfigureAwait(false); + var outcome = await _mediator.Send(new ReceiveExternalTriggerCommand + { + Slug = slug, + PresentedKey = presentedKey, + IdempotencyKey = idempotencyKey, + IdempotencyRejected = idempotencyRejected, + RawBody = body, + }, cancellationToken).ConfigureAwait(false); return outcome.Kind switch { - ExternalTriggerOutcomeKind.NotFound => NotFound(), - ExternalTriggerOutcomeKind.Unauthorized => Unauthorized(), - ExternalTriggerOutcomeKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), - ExternalTriggerOutcomeKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), - ExternalTriggerOutcomeKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId == Guid.Empty ? (Guid?)null : outcome.RunId, deduplicated = false }), + ExternalTriggerDeliveryKind.NotFound => NotFound(), + ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(), + ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), + ExternalTriggerDeliveryKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), + ExternalTriggerDeliveryKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId == Guid.Empty ? (Guid?)null : outcome.RunId, deduplicated = false }), _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), }; } diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs index 6299a2b01..47ba8d0cd 100644 --- a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -2,6 +2,7 @@ using CodeSpace.Core.Services.Identity; using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -49,3 +50,29 @@ public async Task Handle(RenameExternalTriggerCommand request, Cancellatio return Unit.Value; } } + +public sealed class ReceiveExternalTriggerCommandHandler(IExternalTriggerIngress ingress) + : IRequestHandler +{ + public async Task Handle(ReceiveExternalTriggerCommand request, CancellationToken cancellationToken) + { + var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall( + request.Slug, request.PresentedKey, request.IdempotencyKey, request.RawBody, request.IdempotencyRejected), + cancellationToken).ConfigureAwait(false); + + return new ExternalTriggerDeliveryOutcome + { + Kind = outcome.Kind switch + { + ExternalTriggerOutcomeKind.Accepted => ExternalTriggerDeliveryKind.Accepted, + ExternalTriggerOutcomeKind.Deduplicated => ExternalTriggerDeliveryKind.Deduplicated, + ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, + ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, + ExternalTriggerOutcomeKind.InvalidPayload => ExternalTriggerDeliveryKind.InvalidPayload, + _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), + }, + RunId = outcome.RunId, + Errors = outcome.Errors, + }; + } +} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs new file mode 100644 index 000000000..6cc393167 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs @@ -0,0 +1,27 @@ +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Workflows; + +/// +/// An anonymous external trigger delivery: the per-node key is the only credential, not a user or team +/// session, so this declares NO authorization marker — proof is the key digest checked against the +/// trigger row. Marking it would make the pipeline reject a caller that is exactly who the endpoint +/// means to admit. +/// +public sealed record ReceiveExternalTriggerCommand : ICommand +{ + public required string Slug { get; init; } + + /// The key as presented, already normalized from Bearer / X-Api-Key / ?key=. Null when none was sent. + public required string? PresentedKey { get; init; } + + /// Null when the caller sent no Idempotency-Key, or sent only whitespace — deduplication is then the caller's business. + public required string? IdempotencyKey { get; init; } + + /// True when a key WAS presented but is malformed; the ingress answers 400 without staging anything. + public required bool IdempotencyRejected { get; init; } + + /// The delivery body, already bounded by the endpoint's size limit. + public required string RawBody { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs new file mode 100644 index 000000000..9794a70e5 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs @@ -0,0 +1,27 @@ +namespace CodeSpace.Messages.Dtos.Workflows; + +/// +/// What an anonymous external trigger delivery produced. Mirrors the ingress's own outcome so the +/// controller can map it to a status without the outcome type having to cross from CodeSpace.Core: +/// is 202 even when admission refused to stage a run, +/// and only carries a run id the caller already has. +/// +public sealed record ExternalTriggerDeliveryOutcome +{ + public required ExternalTriggerDeliveryKind Kind { get; init; } + + /// The run this call staged or deduplicated onto; when none was. + public required Guid RunId { get; init; } + + /// Non-empty only on — the ingress's own messages. + public required IReadOnlyList Errors { get; init; } +} + +public enum ExternalTriggerDeliveryKind +{ + Accepted, + Deduplicated, + NotFound, + Unauthorized, + InvalidPayload, +} diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs index 4360026ff..88bd22b59 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs @@ -16,7 +16,7 @@ namespace CodeSpace.UnitTests.Architecture; /// /// Why pin it: the marker is what selects a request into an authorization behavior. A request /// without one is not "authorized by default" — it is not authorized at all, and it looks exactly -/// like every other request in the diff. Today the unmarked set is a deliberate, auditable 21; this +/// like every other request in the diff. Today the unmarked set is a deliberate, auditable 47; this /// test makes the 22nd a build failure rather than a discovery. /// /// The two allow-lists are kept SEPARATE on purpose. Adding a sweep is routine; adding an @@ -35,6 +35,7 @@ public class RequestAuthorizationInventoryTests ["SignInCommand"] = "the call that mints the JWT; proof is the password.", ["ReceiveWebhookCommand"] = "provider-signed inbound; proof is the HMAC verified against the per-webhook secret.", ["ReceiveConnectionWebhookCommand"] = "the same inbound, from a group/organization hook; proof is the HMAC verified against THAT hook's own secret, never a repository's.", + ["ReceiveExternalTriggerCommand"] = "an anonymous external trigger delivery; proof is the per-node key, checked against the trigger row's key digest.", ["ResumeWorkflowCallbackCommand"] = "proof is the single-use high-entropy callback token in the URL.", ["WebhookExistsQuery"] = "asked before a delivery's body is read; it answers only whether the hook id names a hook, which the delivery's own 404 already tells the caller.", ["CompleteCredentialOAuthCommand"] = "the provider redirect carries no JWT; proof is the one-time OAuthPendingState row together with the nonce cookie only the starting browser holds. Without it nothing is consumed: the callback only hands the code to the SPA's completion page, where CompleteCredentialOAuthAsInitiatorCommand requires the signed-in starter.", From 8060dcb0925f3e31682dac258133218a94a1d131 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:14:56 +0800 Subject: [PATCH 17/26] Retire external trigger rows when a workflow is deleted DeleteAsync soft-deleted the workflow and its activations but left workflow_external_trigger live. Reveal, rotate and rename resolve a slug through that table alone, so after a delete any team member who knew the slug could still read the plaintext bearer key, and the slug stayed reserved for a workflow nobody could list. Soft-delete rather than shred: it matches how ExternalTriggerDefinitionSync already retires a trigger whose node left the definition (ciphertext retained, partial unique index uq_wet_slug freed because it is filtered on deleted_date IS NULL), it is the convention every other retired row in this repo follows, and it preserves the audit history of which key belonged to which endpoint. The row is now marked deleted in the same SaveChangesAsync as the workflow and its activations, so the delete stays atomic. --- .../Services/Workflows/WorkflowService.cs | 16 +++++++++--- .../ExternalTriggerManagementFlowTests.cs | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index db4bee9f3..56fe87a7e 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -274,16 +274,24 @@ public async Task DeleteAsync(Guid workflowId, Guid teamId, CancellationToken ca var workflow = await LoadWorkflowAsync(workflowId, teamId, cancellationToken).ConfigureAwait(false); if (workflow == null) return; - workflow.DeletedDate = DateTimeOffset.UtcNow; + var now = DateTimeOffset.UtcNow; + workflow.DeletedDate = now; var activations = await _db.WorkflowActivation.Where(a => a.WorkflowId == workflowId && a.DeletedDate == null).ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var a in activations) a.DeletedDate = DateTimeOffset.UtcNow; + foreach (var a in activations) a.DeletedDate = now; + + // Retiring the trigger row is what actually kills the credential: reveal/rotate/rename resolve the slug + // through workflow_external_trigger alone, so a live row here would keep handing out the plaintext key for + // a workflow nobody can list any more. Soft-delete keeps the audit trail and releases the slug, because + // uq_wet_slug is partial on deleted_date IS NULL. + var triggers = await _db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.DeletedDate == null).ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var t in triggers) t.DeletedDate = now; await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation( - "Workflow soft-deleted. WorkflowId={WorkflowId} TeamId={TeamId} ActivationsRemoved={ActivationCount}", - workflowId, teamId, activations.Count); + "Workflow soft-deleted. WorkflowId={WorkflowId} TeamId={TeamId} ActivationsRemoved={ActivationCount} TriggersRemoved={TriggerCount}", + workflowId, teamId, activations.Count, triggers.Count); } public async Task SetEnabledAsync(Guid workflowId, Guid teamId, bool enabled, CancellationToken cancellationToken) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index 4d3227e4b..c5a925293 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -282,6 +282,32 @@ public async Task Another_teams_slug_is_not_found(string operation) (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); } + [Theory] + [InlineData("reveal")] + [InlineData("rotate")] + [InlineData("rename")] + public async Task Deleting_the_workflow_retires_its_trigger_so_the_key_stops_resolving(string operation) + { + var seed = await SeedAsync(); + using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + await scope.Resolve().Send(new DeleteWorkflowCommand { WorkflowId = seed.WorkflowId }); + + using (var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) + { + var error = await Record.ExceptionAsync(() => ManageAsync(verify.Resolve(), operation, seed.Slug, "replacement-" + Guid.NewGuid().ToString("N"))); + error.ShouldBeOfType(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.NotFound); + } + + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + using var audit = fixture.BeginScope(); + var db = audit.Resolve(); + var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId); + row.DeletedDate.ShouldNotBeNull(); + audit.Resolve().Open(row.KeyCiphertext).ShouldBe(seed.Key); + (await db.WorkflowExternalTrigger.CountAsync(t => t.Slug == seed.Slug && t.DeletedDate == null)).ShouldBe(0); + } + [Theory] [InlineData("!!!")] [InlineData("告警")] From 7e7e565b381c39bc44a363d24a53dc7ffdff84d1 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:15:04 +0800 Subject: [PATCH 18/26] Explain an unusable draft slug in the rename confirmation The confirm panel interpolated the derived slug unconditionally, so opening the rename confirmation and then clearing or mangling the draft left the user reading "Rename the live endpoint to ?" with no reason and no way forward. Reuse the guidance the slug field already shows for that case. --- .../workflows/external-trigger-inspector.test.tsx | 11 +++++++++++ .../workflows/external-trigger-inspector.tsx | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx index 72152ce59..f3e4895c2 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.test.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -104,6 +104,17 @@ describe("ExternalTriggerInspector", () => { expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); }); + it("explains an unusable draft slug in an already-open rename confirmation instead of naming an empty target", () => { + renderInspector("prod-alerts", "New Alerts"); + fireEvent.click(screen.getByRole("button", { name: /^rename/i })); + fireEvent.change(screen.getByRole("textbox", { name: /draft slug/i }), { target: { value: "!!!" } }); + const group = screen.getByRole("group", { name: /confirm.*rename/i }); + expect(within(group).getByText(/use an english/i)).toBeInTheDocument(); + expect(within(group).queryByText(/existing url will stop working/i)).not.toBeInTheDocument(); + expect(within(group).getByRole("button", { name: /confirm/i })).toBeDisabled(); + expect(REQUESTS).not.toHaveBeenCalled(); + }); + it("shows a save-first state without any live credential actions", () => { renderInspector(null); expect(screen.getByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx index f66f1ec1e..2c368738e 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -15,6 +15,8 @@ interface IExternalTriggerInspectorProps { onConfigChange: (next: Record) => void; } +const UNUSABLE_SLUG_GUIDANCE = "Cannot derive a usable slug. Use an English name with ASCII letters, digits or underscores."; + function asObject(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } @@ -102,7 +104,7 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig onConfigChange({ ...config, slug: event.target.value })} />

- {usable ? `Derived slug: ${derived}` : "Cannot derive a usable slug. Use an English name with ASCII letters, digits or underscores."} + {usable ? `Derived slug: ${derived}` : UNUSABLE_SLUG_GUIDANCE}

@@ -124,7 +126,9 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig {confirming && (
-

{confirming === "rotate" ? "The old key stops working immediately. Existing callers must use the new key." : `The existing URL will stop working. Rename the live endpoint to ${derived}?`}

+

{confirming === "rotate" + ? "The old key stops working immediately. Existing callers must use the new key." + : usable ? `The existing URL will stop working. Rename the live endpoint to ${derived}?` : UNUSABLE_SLUG_GUIDANCE}

From 238008c02fd9b46d13b9e30054cc1ea3c61b1769 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:27:31 +0800 Subject: [PATCH 19/26] Fix external trigger key handling on retirement --- .../ExternalTriggers/ExternalTriggerDefinitionSync.cs | 6 +++++- .../Services/Workflows/WorkflowService.cs | 11 +++++++---- .../Workflows/ExternalTriggerManagementFlowTests.cs | 8 ++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs index debbdf4eb..9ee56f9c5 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs @@ -14,7 +14,11 @@ public async Task SyncAsync(Guid workflowId, Guid teamId, WorkflowDefinition def var nodeIds = nodes.Select(n => n.Id).ToArray(); var retired = await db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null && !nodeIds.Contains(t.NodeId)) .ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var row in retired) row.DeletedDate = DateTimeOffset.UtcNow; + foreach (var row in retired) + { + row.DeletedDate = DateTimeOffset.UtcNow; + row.KeyCiphertext = string.Empty; + } // Release the partial unique slug before a replacement claims it. The command transaction keeps both atomic. if (retired.Count != 0) await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 56fe87a7e..807cd8c0d 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -281,11 +281,14 @@ public async Task DeleteAsync(Guid workflowId, Guid teamId, CancellationToken ca foreach (var a in activations) a.DeletedDate = now; // Retiring the trigger row is what actually kills the credential: reveal/rotate/rename resolve the slug - // through workflow_external_trigger alone, so a live row here would keep handing out the plaintext key for - // a workflow nobody can list any more. Soft-delete keeps the audit trail and releases the slug, because - // uq_wet_slug is partial on deleted_date IS NULL. + // through workflow_external_trigger alone. The digest and row keep the audit trail; the ciphertext does not + // outlive the endpoint. var triggers = await _db.WorkflowExternalTrigger.Where(t => t.WorkflowId == workflowId && t.DeletedDate == null).ToListAsync(cancellationToken).ConfigureAwait(false); - foreach (var t in triggers) t.DeletedDate = now; + foreach (var t in triggers) + { + t.DeletedDate = now; + t.KeyCiphertext = string.Empty; + } await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index c5a925293..cf73c3718 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -304,7 +304,8 @@ public async Task Deleting_the_workflow_retires_its_trigger_so_the_key_stops_res var db = audit.Resolve(); var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId); row.DeletedDate.ShouldNotBeNull(); - audit.Resolve().Open(row.KeyCiphertext).ShouldBe(seed.Key); + row.KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + row.KeyCiphertext.ShouldBeEmpty(); (await db.WorkflowExternalTrigger.CountAsync(t => t.Slug == seed.Slug && t.DeletedDate == null)).ShouldBe(0); } @@ -364,7 +365,10 @@ public async Task Removing_or_replacing_the_node_retires_its_old_credential(bool (await ReceiveAsync(seed)).Kind.ShouldBe(replace ? ExternalTriggerOutcomeKind.Unauthorized : ExternalTriggerOutcomeKind.NotFound); using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); var rows = await scope.Resolve().WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId).ToArrayAsync(); - rows.Single(t => t.NodeId == "start").DeletedDate.ShouldNotBeNull(); + var retired = rows.Single(t => t.NodeId == "start"); + retired.DeletedDate.ShouldNotBeNull(); + retired.KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + retired.KeyCiphertext.ShouldBeEmpty(); if (replace) { var fresh = await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug }); From 48f674c653a7b6de3bdc1a3eb9a137e54cd4cc73 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:15:25 +0800 Subject: [PATCH 20/26] Support union types in external trigger schemas --- .../ExternalTriggerPayloadValidator.cs | 21 ++++++++++++++--- .../ExternalTriggerPayloadValidatorTests.cs | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs index 22edb3ec6..9f2938038 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -20,10 +20,9 @@ private static void ValidateNode(JsonElement value, JsonElement schema, string p { if (schema.ValueKind != JsonValueKind.Object) return; - if (schema.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String && - !TypeMatches(value, type.GetString()!)) + if (schema.TryGetProperty("type", out var type) && !TypeMatches(value, type)) { - errors.Add($"{path}: expected type '{type.GetString()}' but got {KindName(value.ValueKind)}"); + errors.Add($"{path}: expected type '{DescribeType(type)}' but got {KindName(value.ValueKind)}"); return; } @@ -72,6 +71,13 @@ private static void ValidateObject(JsonElement value, JsonElement schema, string } } + private static bool TypeMatches(JsonElement value, JsonElement type) => type.ValueKind switch + { + JsonValueKind.String => TypeMatches(value, type.GetString()!), + JsonValueKind.Array => type.EnumerateArray().Any(candidate => candidate.ValueKind == JsonValueKind.String && TypeMatches(value, candidate.GetString()!)), + _ => true, + }; + private static bool TypeMatches(JsonElement value, string type) => type switch { "object" => value.ValueKind == JsonValueKind.Object, @@ -84,6 +90,15 @@ private static void ValidateObject(JsonElement value, JsonElement schema, string _ => true, }; + private static string DescribeType(JsonElement type) + { + var names = type.ValueKind == JsonValueKind.Array + ? type.EnumerateArray().Where(candidate => candidate.ValueKind == JsonValueKind.String).Select(candidate => candidate.GetString()).ToArray() + : type.ValueKind == JsonValueKind.String ? [type.GetString()!] : []; + + return names.Length == 0 ? "a valid JSON type (the schema declares none)" : string.Join(" | ", names); + } + private static bool IsInteger(string rawNumber) { var exponentIndex = rawNumber.IndexOfAny(['e', 'E']); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs index d7b856cc9..d82217a85 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -109,5 +109,28 @@ public void Array_items_are_validated_with_indexed_paths() errors.ShouldContain(e => e.Contains("$[1]")); } + [Theory] + [InlineData("\"text\"", true)] + [InlineData("7", true)] + [InlineData("true", true)] + [InlineData("null", false)] + [InlineData("{}", false)] + public void Array_items_accept_a_union_of_json_types(string json, bool isValid) + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema($"[{json}]"), + Schema("""{ "type": "array", "items": { "type": ["string", "number", "boolean"] } }""")); + (errors.Count == 0).ShouldBe(isValid); + } + + [Fact] + public void An_empty_type_union_is_refused_with_a_usable_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"text\""), + Schema("""{ "type": [] }""")); + errors.ShouldHaveSingleItem().ShouldContain("the schema declares none"); + } + private static JsonElement Schema(string json) => JsonDocument.Parse(json).RootElement.Clone(); } From 41b1c3f7853b1f0c977381e8d0c00edf99cbb234 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:23:58 +0800 Subject: [PATCH 21/26] Stabilize model call capture polling test --- .../workflows/ModelCallsPanel.test.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/workflows/ModelCallsPanel.test.tsx b/frontend/src/components/workflows/ModelCallsPanel.test.tsx index 71feeb93a..05c6703d8 100644 --- a/frontend/src/components/workflows/ModelCallsPanel.test.tsx +++ b/frontend/src/components/workflows/ModelCallsPanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { workflowsApi, type WorkflowRunModelCallDetailMetadata, type WorkflowRunModelCallPage } from "@/api/workflows"; @@ -28,7 +28,10 @@ const page: WorkflowRunModelCallPage = { }], }; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); describe("ModelCallsPanel", () => { it("leaves the loading state when the run becomes inaccessible", async () => { @@ -71,6 +74,7 @@ describe("ModelCallsPanel", () => { }); it("keeps refreshing pending capture metadata after the workflow is terminal", async () => { + vi.useFakeTimers(); vi.spyOn(workflowsApi, "pageRunModelCalls").mockResolvedValue(page); const pending = { ...page.items[0], @@ -83,15 +87,15 @@ describe("ModelCallsPanel", () => { bodies: [{ ...pending.bodies[0], captureHealth: "Available" }], } as WorkflowRunModelCallDetailMetadata; const detailSpy = vi.spyOn(workflowsApi, "getRunModelCallById").mockResolvedValueOnce(pending).mockResolvedValueOnce(available); - vi.spyOn(window, "setInterval").mockImplementation((handler: TimerHandler) => { - queueMicrotask(() => { if (typeof handler === "function") handler(); }); - return 1; - }); render(); - fireEvent.click(await screen.findByRole("button", { name: /agent\.model-call/i })); - await screen.findByText("Stable call id"); - await waitFor(() => expect(detailSpy).toHaveBeenCalledTimes(2)); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + fireEvent.click(screen.getByRole("button", { name: /agent\.model-call/i })); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByText("Stable call id")).toBeTruthy(); + + await act(async () => { await vi.advanceTimersByTimeAsync(3_000); }); + expect(detailSpy).toHaveBeenCalledTimes(2); }); }); From f60497bd89394fc873c2fd6af1fcadc886364cb1 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:04:16 +0800 Subject: [PATCH 22/26] Harden external trigger ingress --- .../Controllers/ExternalTriggersController.cs | 23 +- .../ExternalTriggerCommandHandlers.cs | 18 ++ ...low_external_triggers_slug_reservation.sql | 31 +++ .../ExternalTriggerDefinitionSync.cs | 17 ++ .../ExternalTriggerIdempotency.cs | 6 +- .../ExternalTriggerIngress.cs | 83 +++++-- .../ExternalTriggerPayloadValidator.cs | 218 +++++++++++++++++- .../ExternalTriggerRunAdmission.cs | 31 +++ .../IExternalTriggerIngress.cs | 17 +- .../Services/Workflows/WorkflowService.cs | 7 +- .../ReceiveExternalTriggerCommand.cs | 12 +- .../WorkflowRunRequestRejectionReasons.cs | 3 + .../ExternalTriggerDeliveryOutcome.cs | 1 + .../ExternalTriggerEndpointE2ETests.cs | 54 ++++- .../ExternalTriggerIngressContractTests.cs | 4 +- .../ExternalTriggerIngressFlowTests.cs | 26 ++- .../ExternalTriggerManagementFlowTests.cs | 23 ++ .../Workflows/WorkflowRoundTripFlowTests.cs | 1 + .../Workflows/WorkflowSlugBackfillTests.cs | 47 ++++ .../ExternalTriggerIdempotencyTests.cs | 2 +- .../ExternalTriggerPayloadValidatorTests.cs | 98 +++++++- .../external-trigger-inspector.test.tsx | 4 +- .../workflows/external-trigger-inspector.tsx | 2 +- 23 files changed, 668 insertions(+), 60 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs index 058f8212f..455c7aa9c 100644 --- a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -27,17 +27,21 @@ public class ExternalTriggersController : ControllerBase public ExternalTriggersController(IMediator mediator) { _mediator = mediator; } - [HttpPost("{slug}/run")] + [HttpPost("{slug:length(1,64)}/run")] [RequestSizeLimit(MaxDeliveryBodyBytes)] - public async Task Run([FromRoute] string slug, [FromQuery] string? key, CancellationToken cancellationToken) + public async Task Run([FromRoute] string slug, CancellationToken cancellationToken) { - var body = await Request.ReadBoundedBodyAsync(MaxDeliveryBodyBytes, cancellationToken).ConfigureAwait(false); - if (body == null) return StatusCode(StatusCodes.Status413PayloadTooLarge); - var authorization = Request.Headers.Authorization.ToString(); var bearer = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? authorization[7..].Trim() : null; var apiKey = Request.Headers["X-Api-Key"].ToString(); - var presentedKey = !string.IsNullOrEmpty(bearer) ? bearer : !string.IsNullOrEmpty(apiKey) ? apiKey : string.IsNullOrEmpty(key) ? null : key; + var presentedKey = !string.IsNullOrEmpty(bearer) ? bearer : !string.IsNullOrEmpty(apiKey) ? apiKey : null; + + var verification = await _mediator.Send(new VerifyExternalTriggerCommand { Slug = slug, PresentedKey = presentedKey }, cancellationToken).ConfigureAwait(false); + if (verification.Kind is ExternalTriggerDeliveryKind.NotFound or ExternalTriggerDeliveryKind.Unauthorized) + return verification.Kind == ExternalTriggerDeliveryKind.NotFound ? NotFound() : Unauthorized(); + + var body = await Request.ReadBoundedBodyAsync(MaxDeliveryBodyBytes, cancellationToken).ConfigureAwait(false); + if (body == null) return StatusCode(StatusCodes.Status413PayloadTooLarge); var idempotencyHeader = Request.Headers["Idempotency-Key"].ToString(); var idempotencyKey = string.IsNullOrWhiteSpace(idempotencyHeader) ? null : idempotencyHeader; @@ -56,9 +60,16 @@ public async Task Run([FromRoute] string slug, [FromQuery] string ExternalTriggerDeliveryKind.NotFound => NotFound(), ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(), ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), + ExternalTriggerDeliveryKind.RateLimited => RateLimited(), ExternalTriggerDeliveryKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), ExternalTriggerDeliveryKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId == Guid.Empty ? (Guid?)null : outcome.RunId, deduplicated = false }), _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), }; } + + private IActionResult RateLimited() + { + Response.Headers.RetryAfter = ((int)ExternalTriggerRunAdmission.Window.TotalSeconds).ToString(); + return StatusCode(StatusCodes.Status429TooManyRequests, new { retryAfterSeconds = (int)ExternalTriggerRunAdmission.Window.TotalSeconds }); + } } diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs index 47ba8d0cd..9d5c375b2 100644 --- a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -69,6 +69,7 @@ public async Task Handle(ReceiveExternalTriggerC ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, ExternalTriggerOutcomeKind.InvalidPayload => ExternalTriggerDeliveryKind.InvalidPayload, + ExternalTriggerOutcomeKind.RateLimited => ExternalTriggerDeliveryKind.RateLimited, _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), }, RunId = outcome.RunId, @@ -76,3 +77,20 @@ public async Task Handle(ReceiveExternalTriggerC }; } } + +public sealed class VerifyExternalTriggerCommandHandler(IExternalTriggerIngress ingress) + : IRequestHandler +{ + public async Task Handle(VerifyExternalTriggerCommand request, CancellationToken cancellationToken) + { + var verification = await ingress.VerifyAsync(request.Slug, request.PresentedKey, cancellationToken).ConfigureAwait(false); + var kind = verification.Kind switch + { + ExternalTriggerOutcomeKind.Accepted => ExternalTriggerDeliveryKind.Accepted, + ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, + ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, + _ => throw new InvalidOperationException($"Unexpected external trigger verification outcome: {verification.Kind}"), + }; + return new ExternalTriggerVerificationOutcome(kind); + } +} diff --git a/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql new file mode 100644 index 000000000..480ecd30e --- /dev/null +++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0252_workflow_external_triggers_slug_reservation.sql @@ -0,0 +1,31 @@ +-- 0252_workflow_external_triggers_slug_reservation.sql +-- The management route /api/workflows/external-triggers is a literal GET sibling of /api/workflows/{idOrSlug}. +-- A workflow already holding that slug becomes unreachable, so move each live row to the same -N scheme the +-- runtime allocator uses, preserving soft-deleted rows because their slug is audit history. + +WITH reserved AS ( + SELECT id, team_id, slug + FROM workflow + WHERE slug = 'external-triggers' AND deleted_date IS NULL +), +candidates AS ( + SELECT r.id, r.team_id, r.slug, n + FROM reserved r + CROSS JOIN LATERAL generate_series(2, 1000) AS n + WHERE NOT EXISTS ( + SELECT 1 + FROM workflow w + WHERE w.team_id = r.team_id + AND w.slug = 'external-triggers-' || n::text + AND w.deleted_date IS NULL + ) +) +UPDATE workflow w +SET slug = 'external-triggers-' || c.n::text +FROM candidates c +WHERE w.id = c.id + AND c.n = ( + SELECT MIN(c2.n) + FROM candidates c2 + WHERE c2.id = c.id + ); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs index 9ee56f9c5..a421ca93e 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs @@ -8,6 +8,23 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public sealed class ExternalTriggerDefinitionSync(CodeSpaceDbContext db, IExternalTriggerStore store) : IScopedDependency { + /// + /// Save-time schema validation runs before any workflow row is written, so an unsupported keyword cannot leave + /// a half-created workflow behind. + /// + public static void ValidateSchemas(WorkflowDefinition definition) + { + var schemaErrors = definition.Nodes + .Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey) + .SelectMany(node => node.Config.ValueKind == JsonValueKind.Object + && node.Config.TryGetProperty("inputSchema", out var schema) + ? ExternalTriggerPayloadValidator.ValidateSchema(schema) + : []) + .ToArray(); + if (schemaErrors.Length != 0) + throw new WorkflowValidationException(schemaErrors); + } + public async Task SyncAsync(Guid workflowId, Guid teamId, WorkflowDefinition definition, Guid actorId, CancellationToken cancellationToken) { var nodes = definition.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToArray(); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs index bb39413ea..7798efd40 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs @@ -12,5 +12,9 @@ public static bool IsWellFormed(string? value) => !string.IsNullOrEmpty(value) && value.Length <= MaxLength && value.All(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '_' or '~' or '-'); - public static string KeyFor(string value, Guid activationId) => $"{ExternalTriggerSlug.TypeKey}:{value}:{activationId:N}"; + /// + /// Scope follows the durable trigger row, not the activation row: ReplaceActivationsAsync recreates activations on + /// every workflow save, so an activation-scoped key would stop deduplicating after an ordinary edit. + /// + public static string KeyFor(string value, Guid triggerId) => $"{ExternalTriggerSlug.TypeKey}:{value}:{triggerId:N}"; } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs index 59d353418..9dce027ab 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -6,6 +6,7 @@ using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Agents.Authority.Exceptions; using CodeSpace.Core.Services.Completion.Exceptions; +using CodeSpace.Core.Services.Webhooks; using CodeSpace.Core.Services.Workflows.Dispatch; using CodeSpace.Core.Services.Workflows.RunSources; using CodeSpace.Messages.Constants; @@ -29,13 +30,14 @@ public sealed class ExternalTriggerIngress( IWorkflowRunDispatcher runDispatcher, IPostCommitActions postCommit, IIngestionAuditor auditor, + ExternalTriggerRunAdmission admission, ILogger logger) : IExternalTriggerIngress { - public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct) + public async Task VerifyAsync(string slug, string? presentedKey, CancellationToken ct) { var trigger = await db.WorkflowExternalTrigger.AsNoTracking() - .SingleOrDefaultAsync(t => t.Slug == call.Slug && t.DeletedDate == null, ct).ConfigureAwait(false); - if (trigger is null) return Outcome(ExternalTriggerOutcomeKind.NotFound); + .SingleOrDefaultAsync(t => t.Slug == slug && t.DeletedDate == null, ct).ConfigureAwait(false); + if (trigger is null) return Verification(ExternalTriggerOutcomeKind.NotFound); var activations = await db.WorkflowActivation.AsNoTracking().Include(a => a.Workflow) .Where(a => a.WorkflowId == trigger.WorkflowId && a.TypeKey == ExternalTriggerSlug.TypeKey @@ -45,22 +47,43 @@ public async Task ReceiveAsync(ExternalTriggerCall call, { if (activations.Count > 1) logger.LogWarning("External trigger workflow {WorkflowId} has {Count} live activations; refusing ambiguous endpoint", trigger.WorkflowId, activations.Count); - return Outcome(ExternalTriggerOutcomeKind.NotFound); + return Verification(ExternalTriggerOutcomeKind.NotFound); } var activation = activations[0]; var definitionJson = await db.WorkflowVersion.AsNoTracking() .Where(v => v.WorkflowId == activation.WorkflowId && v.Version == activation.Workflow.LatestVersion) .Select(v => v.DefinitionJson).SingleOrDefaultAsync(ct).ConfigureAwait(false); - if (definitionJson is null) return Outcome(ExternalTriggerOutcomeKind.NotFound); + if (definitionJson is null) return Verification(ExternalTriggerOutcomeKind.NotFound); var definition = JsonSerializer.Deserialize(definitionJson, WorkflowJson.Options); var nodes = definition?.Nodes.Where(n => n.TypeKey == ExternalTriggerSlug.TypeKey).ToList(); - if (nodes is not { Count: 1 } || nodes[0].Id != trigger.NodeId) return Outcome(ExternalTriggerOutcomeKind.NotFound); + if (nodes is not { Count: 1 } || nodes[0].Id != trigger.NodeId) return Verification(ExternalTriggerOutcomeKind.NotFound); var config = nodes[0].Config; - if (call.PresentedKey is null || !CryptographicOperations.FixedTimeEquals( - Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(call.PresentedKey)), Encoding.ASCII.GetBytes(trigger.KeyDigest))) - return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + if (presentedKey is null || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(presentedKey)), Encoding.ASCII.GetBytes(trigger.KeyDigest))) + return Verification(ExternalTriggerOutcomeKind.Unauthorized); + + var schema = config.ValueKind == JsonValueKind.Object + && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object + ? declared : default; + + return new ExternalTriggerVerification( + ExternalTriggerOutcomeKind.Accepted, + trigger.Id, + activation.Id, + activation.WorkflowId, + activation.Workflow.TeamId, + activation.Workflow.LatestVersion, + ActivationAuthoritySnapshot.Serialize(activation), + schema); + } + + public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct) + { + var verification = await VerifyAsync(call.Slug, call.PresentedKey, ct).ConfigureAwait(false); + if (verification.Kind != ExternalTriggerOutcomeKind.Accepted) + return Outcome(verification.Kind); if (call.IdempotencyRejected) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, @@ -73,37 +96,41 @@ public async Task ReceiveAsync(ExternalTriggerCall call, { if (body.RootElement.ValueKind != JsonValueKind.Object) return InvalidBody(); using var fallback = JsonDocument.Parse("""{"type":"object"}"""); - var schema = config.ValueKind == JsonValueKind.Object - && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object - ? declared : fallback.RootElement; + var schema = verification.InputSchema.ValueKind == JsonValueKind.Object ? verification.InputSchema : fallback.RootElement; var errors = ExternalTriggerPayloadValidator.Validate(body.RootElement, schema); if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); var envelope = new RunSourceEnvelope { - TeamId = activation.Workflow.TeamId, - WorkflowId = activation.WorkflowId, - WorkflowVersion = activation.Workflow.LatestVersion, + TeamId = verification.TeamId, + WorkflowId = verification.WorkflowId, + WorkflowVersion = verification.WorkflowVersion, SourceType = ExternalTriggerSlug.TypeKey, ActorType = WorkflowRunActorTypes.Webhook, ActorId = null, CreatedBy = SystemUsers.SeederId, - ActivationId = activation.Id, - ActivationSnapshotJson = ActivationAuthoritySnapshot.Serialize(activation), + ActivationId = verification.ActivationId, + ActivationSnapshotJson = verification.ActivationSnapshotJson, NormalizedPayloadJson = body.RootElement.GetRawText(), - IdempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, activation.Id), + IdempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, verification.TriggerId), }; + if (!admission.TryAcquire(verification.TriggerId)) + { + await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.TriggerRateLimited, "per-trigger run admission refused", ct).ConfigureAwait(false); + return Outcome(ExternalTriggerOutcomeKind.RateLimited); + } + Guid runId; try { runId = await runStarter.StartAsync(envelope, ct).ConfigureAwait(false); } catch (AgentAuthorityDeniedException ex) { - await AuditRefusalAsync(activation, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); + await AuditRefusalAsync(verification, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.Accepted); } catch (CompletionAdmissionRefusedException ex) { - await AuditRefusalAsync(activation, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); + await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.Accepted); } @@ -123,20 +150,26 @@ public async Task ReceiveAsync(ExternalTriggerCall call, } } - private async Task AuditRefusalAsync(WorkflowActivation activation, ExternalTriggerCall call, string reason, string detail, CancellationToken ct) + private async Task AuditRefusalAsync(ExternalTriggerVerification verification, ExternalTriggerCall call, string reason, string detail, CancellationToken ct) { - logger.LogWarning("External trigger activation {ActivationId} workflow {WorkflowId} refused: {Reason} {Detail}", activation.Id, activation.WorkflowId, reason, detail); + logger.LogWarning("External trigger activation {ActivationId} workflow {WorkflowId} refused: {Reason} {Detail}", verification.ActivationId, verification.WorkflowId, reason, detail); await auditor.WriteWebhookRejectedAsync(new WebhookRejectionContext { - TeamId = activation.Workflow.TeamId, + TeamId = verification.TeamId, SourceType = ExternalTriggerSlug.TypeKey, ExternalEventId = call.IdempotencyKey, Reason = reason, - Detail = $"Activation {activation.Id} workflow {activation.WorkflowId}: {detail}", - DedupKey = $"rejected:{call.Slug}:{call.IdempotencyKey}", + Detail = $"Activation {verification.ActivationId} workflow {verification.WorkflowId}: {detail}", + DedupKey = BuildRefusalWindowKey(verification.TriggerId, reason, DateTimeOffset.UtcNow), }, ct).ConfigureAwait(false); } + private static string BuildRefusalWindowKey(Guid triggerId, string reason, DateTimeOffset now) => + $"refused:{triggerId:N}:{reason}:{now.UtcTicks / WebhookIngestionService.RefusalAuditWindow.Ticks}"; + + private static ExternalTriggerVerification Verification(ExternalTriggerOutcomeKind kind) => + new(kind, Guid.Empty, Guid.Empty, Guid.Empty, Guid.Empty, 0, string.Empty, default); + private static ExternalTriggerOutcome InvalidBody() => Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: ["$: body must be a JSON object"]); private static ExternalTriggerOutcome Outcome(ExternalTriggerOutcomeKind kind, Guid runId = default, IReadOnlyList? errors = null) => new(kind, runId, errors ?? []); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs index 9f2938038..19b337bbe 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Numerics; +using System.Globalization; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; @@ -9,15 +10,52 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; /// public static class ExternalTriggerPayloadValidator { + public const int MaxErrors = 20; + public const int MaxNumberTokenLength = 256; + public const int MaxSchemaDepth = 64; + public const int MaxNodes = 10_000; + + /// + /// The schema keywords the HTTP contract actually enforces. A keyword outside this set is refused at save time + /// rather than silently weakening the published contract. + /// + private static readonly HashSet SupportedSchemaKeywords = new(StringComparer.Ordinal) + { + "type", "enum", "required", "properties", "additionalProperties", "items", "maxLength", "title", "description", "default", + }; + public static IReadOnlyList Validate(JsonElement body, JsonElement inputSchema) { var errors = new List(); - ValidateNode(body, inputSchema, "$", errors); + var budget = new ValidationBudget(); + ValidateRepresentable(body, errors, budget, depth: 0); + ValidateNode(body, inputSchema, "$", errors, budget, depth: 0); + return errors; + } + + /// Save-time gate for the author-declared schema; payload validation does not guess unsupported semantics. + public static IReadOnlyList ValidateSchema(JsonElement schema) + { + var errors = new List(); + var budget = new ValidationBudget(); + ValidateSchemaNode(schema, "$", errors, budget, depth: 0); return errors; } - private static void ValidateNode(JsonElement value, JsonElement schema, string path, List errors) + private static void ValidateNode(JsonElement value, JsonElement schema, string path, List errors, ValidationBudget budget, int depth) { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (depth > MaxSchemaDepth) + { + errors.Add($"{path}: schema or value is too deep (max {MaxSchemaDepth})"); + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: schema or value contains too many nodes"); + budget.Exhausted = true; + return; + } if (schema.ValueKind != JsonValueKind.Object) return; if (schema.TryGetProperty("type", out var type) && !TypeMatches(value, type)) @@ -28,31 +66,35 @@ private static void ValidateNode(JsonElement value, JsonElement schema, string p if (schema.TryGetProperty("enum", out var enumValues) && enumValues.ValueKind == JsonValueKind.Array && !enumValues.EnumerateArray().Any(candidate => JsonValueEquals(value, candidate))) - errors.Add($"{path}: value '{value}' is not one of the allowed enum values"); + errors.Add($"{path}: value '{DisplayValue(value)}' is not one of the allowed enum values"); if (value.ValueKind == JsonValueKind.Object) { - ValidateObject(value, schema, path, errors); + ValidateObject(value, schema, path, errors, budget, depth); } else if (value.ValueKind == JsonValueKind.Array && schema.TryGetProperty("items", out var items)) { var index = 0; foreach (var item in value.EnumerateArray()) - ValidateNode(item, items, $"{path}[{index++}]", errors); + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + ValidateNode(item, items, $"{path}[{index++}]", errors, budget, depth + 1); + } } else if (value.ValueKind == JsonValueKind.String && schema.TryGetProperty("maxLength", out var maxLength) && - maxLength.TryGetInt32(out var maximum) && value.GetString()!.Length > maximum) + maxLength.TryGetInt32(out var maximum) && TryGetString(value, out var text) && text.Length > maximum) { errors.Add($"{path}: string exceeds maximum length {maximum}"); } } - private static void ValidateObject(JsonElement value, JsonElement schema, string path, List errors) + private static void ValidateObject(JsonElement value, JsonElement schema, string path, List errors, ValidationBudget budget, int depth) { if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array) { foreach (var property in required.EnumerateArray()) { + if (errors.Count >= MaxErrors) return; if (property.ValueKind == JsonValueKind.String && !value.TryGetProperty(property.GetString()!, out _)) errors.Add($"{path}.{property.GetString()}: required property is missing"); } @@ -64,13 +106,148 @@ private static void ValidateObject(JsonElement value, JsonElement schema, string foreach (var property in value.EnumerateObject()) { + if (errors.Count >= MaxErrors || budget.Exhausted) return; if (hasProperties && properties.TryGetProperty(property.Name, out var propertySchema)) - ValidateNode(property.Value, propertySchema, $"{path}.{property.Name}", errors); + ValidateNode(property.Value, propertySchema, $"{path}.{property.Name}", errors, budget, depth + 1); else if (closesObject) errors.Add($"{path}.{property.Name}: additional property is not allowed"); } } + private static void ValidateSchemaNode(JsonElement schema, string path, List errors, ValidationBudget budget, int depth) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (schema.ValueKind != JsonValueKind.Object) return; + if (depth > MaxSchemaDepth) + { + errors.Add($"{path}: schema is too deep (max {MaxSchemaDepth})"); + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: schema contains too many nodes"); + budget.Exhausted = true; + return; + } + + foreach (var keyword in schema.EnumerateObject()) + { + if (!SupportedSchemaKeywords.Contains(keyword.Name)) + errors.Add($"{path}.{keyword.Name}: schema keyword '{keyword.Name}' is not supported"); + } + + if (schema.TryGetProperty("type", out var type) && !TypeKeywordIsSupported(type)) + errors.Add($"{path}.type: type '{type}' is not supported"); + + if (schema.TryGetProperty("additionalProperties", out var additionalProperties) && + additionalProperties.ValueKind == JsonValueKind.Object) + ValidateSchemaNode(additionalProperties, $"{path}.additionalProperties", errors, budget, depth + 1); + + if (schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object) + { + foreach (var property in properties.EnumerateObject()) + ValidateSchemaNode(property.Value, $"{path}.properties.{property.Name}", errors, budget, depth + 1); + } + + if (schema.TryGetProperty("items", out var items)) + ValidateSchemaNode(items, $"{path}.items", errors, budget, depth + 1); + } + + private static bool TypeKeywordIsSupported(JsonElement type) => type.ValueKind switch + { + JsonValueKind.String => IsSupportedType(type.GetString()!), + JsonValueKind.Array => type.EnumerateArray().All(candidate => candidate.ValueKind == JsonValueKind.String && IsSupportedType(candidate.GetString()!)), + _ => false, + }; + + private static bool IsSupportedType(string type) => type is "object" or "array" or "string" or "integer" or "number" or "boolean" or "null"; + + /// + /// jsonb rejects lone surrogates and numeric values it cannot encode. Reject both while the payload is still a + /// caller-visible 400, instead of letting the write fail as a server error. + /// + private static void ValidateRepresentable(JsonElement value, List errors, ValidationBudget budget, int depth) + { + if (errors.Count >= MaxErrors || budget.Exhausted) return; + if (depth > MaxSchemaDepth) + { + errors.Add($"$: payload is too deep (max {MaxSchemaDepth})"); + budget.Exhausted = true; + return; + } + if (++budget.Nodes > MaxNodes) + { + errors.Add("$: payload contains too many nodes"); + budget.Exhausted = true; + return; + } + + switch (value.ValueKind) + { + case JsonValueKind.String: + if (!TryGetString(value, out var text)) + { + errors.Add("$: string is not representable"); + break; + } + if (!IsWellFormedUtf16(text)) + errors.Add("$: string contains text that cannot be stored"); + break; + case JsonValueKind.Number: + var raw = value.GetRawText(); + if (raw.Length > MaxNumberTokenLength) + { + errors.Add("$: number token is too long"); + break; + } + if (!TryGetRepresentableNumber(raw)) + errors.Add("$: number is not representable"); + break; + case JsonValueKind.Object: + foreach (var property in value.EnumerateObject()) + ValidateRepresentable(property.Value, errors, budget, depth + 1); + break; + case JsonValueKind.Array: + foreach (var item in value.EnumerateArray()) + ValidateRepresentable(item, errors, budget, depth + 1); + break; + } + } + + private static bool TryGetRepresentableNumber(string raw) + { + var exponentIndex = raw.IndexOfAny(['e', 'E']); + if (exponentIndex >= 0) + { + var exponentText = raw[(exponentIndex + 1)..]; + if (exponentText.StartsWith('+') || exponentText.StartsWith('-')) exponentText = exponentText[1..]; + if (exponentText.Length > 20) return false; + } + + return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number); + } + + private static bool TryGetString(JsonElement value, out string text) + { + try { text = value.GetString()!; return true; } + catch (InvalidOperationException) { text = string.Empty; return false; } + } + + /// Postgres jsonb rejects unpaired UTF-16 surrogates; .NET strings can still contain them. + private static bool IsWellFormedUtf16(string value) + { + for (var i = 0; i < value.Length; i++) + { + if (char.IsHighSurrogate(value[i])) + { + if (i + 1 >= value.Length || !char.IsLowSurrogate(value[i + 1])) return false; + i++; + } + else if (char.IsLowSurrogate(value[i])) return false; + } + return true; + } + private static bool TypeMatches(JsonElement value, JsonElement type) => type.ValueKind switch { JsonValueKind.String => TypeMatches(value, type.GetString()!), @@ -103,7 +280,7 @@ private static bool IsInteger(string rawNumber) { var exponentIndex = rawNumber.IndexOfAny(['e', 'E']); var mantissa = exponentIndex < 0 ? rawNumber : rawNumber[..exponentIndex]; - var exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(rawNumber[(exponentIndex + 1)..], System.Globalization.CultureInfo.InvariantCulture); + var exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(rawNumber[(exponentIndex + 1)..], CultureInfo.InvariantCulture); var decimalIndex = mantissa.IndexOf('.'); var fractionalDigits = decimalIndex < 0 ? 0 : mantissa.Length - decimalIndex - 1; var digits = mantissa.Replace(".", "", StringComparison.Ordinal).TrimStart('-'); @@ -123,10 +300,12 @@ private static bool JsonValueEquals(JsonElement left, JsonElement right) return left.ValueKind switch { - JsonValueKind.String => left.GetString() == right.GetString(), + JsonValueKind.String => TryGetString(left, out var leftText) && TryGetString(right, out var rightText) + ? leftText == rightText + : RawTextEquals(left, right), JsonValueKind.Number => left.TryGetDouble(out var leftNumber) && right.TryGetDouble(out var rightNumber) && leftNumber == rightNumber, JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, - _ => left.GetRawText() == right.GetRawText(), + _ => RawTextEquals(left, right), }; } @@ -135,4 +314,21 @@ private static bool JsonValueEquals(JsonElement left, JsonElement right) JsonValueKind.True or JsonValueKind.False => "boolean", _ => kind.ToString().ToLowerInvariant(), }; + + private static string DisplayValue(JsonElement value) => value.ValueKind == JsonValueKind.String && !TryGetString(value, out _) + ? value.GetRawText() + : value.ToString(); + + /// Container raw text can still contain an unreadable string escape, so compare tokens without reading strings. + private static bool RawTextEquals(JsonElement left, JsonElement right) + { + try { return left.GetRawText() == right.GetRawText(); } + catch (InvalidOperationException) { return false; } + } + + private sealed class ValidationBudget + { + public int Nodes { get; set; } + public bool Exhausted { get; set; } + } } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs new file mode 100644 index 000000000..e14c925d1 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; +using System.Threading.RateLimiting; +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +/// +/// A budget on authenticated deliveries that can actually reach RunStarter. Request-rate limiting happens before +/// authentication and is keyed by the sender's address and target; it cannot express a leaked key repeatedly starting +/// runs. This limiter is process-local, so its effective ceiling scales with the number of API replicas. +/// +public sealed class ExternalTriggerRunAdmission : ISingletonDependency, IDisposable +{ + public const int PermitsPerWindow = 60; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + private readonly ConcurrentDictionary _limiters = new(); + + public bool TryAcquire(Guid triggerId) + { + var limiter = _limiters.GetOrAdd(triggerId, _ => new FixedWindowRateLimiter( + new FixedWindowRateLimiterOptions { PermitLimit = PermitsPerWindow, Window = Window, QueueLimit = 0 })); + return limiter.AttemptAcquire(1).IsAcquired; + } + + public void Dispose() + { + foreach (var limiter in _limiters.Values) limiter.Dispose(); + _limiters.Clear(); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs index b863cd777..a0297d986 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs @@ -1,14 +1,29 @@ using CodeSpace.Core.DependencyInjection; +using System.Text.Json; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public interface IExternalTriggerIngress : IScopedDependency { + /// Resolves and authenticates the endpoint before any body is read. + Task VerifyAsync(string slug, string? presentedKey, CancellationToken ct); + Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct); } +/// The authenticated endpoint identity retained between body read and delivery. +public sealed record ExternalTriggerVerification( + ExternalTriggerOutcomeKind Kind, + Guid TriggerId, + Guid ActivationId, + Guid WorkflowId, + Guid TeamId, + int WorkflowVersion, + string ActivationSnapshotJson, + JsonElement InputSchema); + public sealed record ExternalTriggerCall(string Slug, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected); public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors); -public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, InvalidPayload } +public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, InvalidPayload, RateLimited } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 807cd8c0d..6afb89163 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -168,6 +168,7 @@ public async Task> ListAsync(Guid teamId, Cancell public async Task CreateAsync(Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, bool enabled, CancellationToken cancellationToken) { definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; + ExternalTriggerDefinitionSync.ValidateSchemas(definition); EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); @@ -229,6 +230,7 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, CancellationToken cancellationToken) { definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; + ExternalTriggerDefinitionSync.ValidateSchemas(definition); EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); @@ -2231,10 +2233,11 @@ private async Task DeriveAvailableSlugAsync(Guid teamId, string? name, C /// /// Slugs that would be shadowed by a literal api/workflows/… GET route — ASP.NET gives those /// literals precedence over the {idOrSlug} parameter, so a workflow slugged to one would be - /// unreachable. Forced to a -N variant at derivation. Keep in sync with the 0099 backfill list. + /// unreachable. Forced to a -N variant at derivation. external-triggers was added after + /// the 0099 backfill and is repaired by 0252 for existing rows. /// private static readonly HashSet ReservedSlugs = - new(new[] { "runs", "node-manifests", "system-variables", "decisions" }, StringComparer.Ordinal); + new(new[] { "runs", "node-manifests", "system-variables", "decisions", "external-triggers" }, StringComparer.Ordinal); private static bool IsWorkflowSlugUniqueViolation(DbUpdateException ex) => ex.InnerException is Npgsql.PostgresException { SqlState: "23505" } pg diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs index 6cc393167..06584270f 100644 --- a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs @@ -13,7 +13,7 @@ public sealed record ReceiveExternalTriggerCommand : ICommandThe key as presented, already normalized from Bearer / X-Api-Key / ?key=. Null when none was sent. + /// The key as presented, already normalized from Bearer / X-Api-Key. Null when none was sent. public required string? PresentedKey { get; init; } /// Null when the caller sent no Idempotency-Key, or sent only whitespace — deduplication is then the caller's business. @@ -25,3 +25,13 @@ public sealed record ReceiveExternalTriggerCommand : ICommandThe delivery body, already bounded by the endpoint's size limit. public required string RawBody { get; init; } } + +/// Anonymous identity check only; the controller sends this before reading any request body. +public sealed record VerifyExternalTriggerCommand : ICommand +{ + public required string Slug { get; init; } + public required string? PresentedKey { get; init; } +} + +/// Wire-safe verification result, mirroring the ingress kind without exposing its internal identity record. +public sealed record ExternalTriggerVerificationOutcome(ExternalTriggerDeliveryKind Kind); diff --git a/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs b/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs index f9f054607..4351cced7 100644 --- a/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs +++ b/backend/src/CodeSpace.Messages/Constants/WorkflowRunRequestRejectionReasons.cs @@ -104,4 +104,7 @@ public static class WorkflowRunRequestRejectionReasons /// to start runs on demand; a push moves the head, so new commits are never held back. /// public const string PullRequestDebounced = "pull_request_debounced"; + + /// A trigger's authenticated delivery rate exceeded its committed admission budget. The body is already known-good; the caller should retry in the next window. + public const string TriggerRateLimited = "trigger_rate_limited"; } diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs index 9794a70e5..20ecfa318 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs @@ -24,4 +24,5 @@ public enum ExternalTriggerDeliveryKind NotFound, Unauthorized, InvalidPayload, + RateLimited, } diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index 637711750..7fa4891de 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -149,10 +149,8 @@ public async Task A_missing_or_wrong_key_is_401_before_validation(string? author [InlineData("bearer")] [InlineData("mixed-bearer")] [InlineData("api-key")] - [InlineData("query")] [InlineData("empty-bearer-fallback")] [InlineData("wrong-scheme-fallback")] - [InlineData("api-key-before-query")] public async Task The_supported_key_forms_start_a_run(string form) { var seed = await SeedAsync(); @@ -165,8 +163,7 @@ public async Task The_supported_key_forms_start_a_run(string form) _ => null, }; var apiKey = form is "api-key" or "empty-bearer-fallback" or "wrong-scheme-fallback" or "api-key-before-query" ? seed.Key : null; - var url = Url(seed) + (form == "query" ? "?key=" + seed.Key : form == "api-key-before-query" ? "?key=wrong" : ""); - using var response = await PostAsync(url, BODY, authorization, apiKey); + using var response = await PostAsync(Url(seed), BODY, authorization, apiKey); response.StatusCode.ShouldBe(HttpStatusCode.Accepted, await TenantHarness.DescribeAsync(response)); var payload = await response.Content.ReadFromJsonAsync(); payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); @@ -174,6 +171,15 @@ public async Task The_supported_key_forms_start_a_run(string form) payload.GetProperty("runId").GetGuid().ShouldBe(runs.ShouldHaveSingleItem()); } + [Fact] + public async Task A_key_in_the_query_string_is_not_accepted() + { + var seed = await SeedAsync(); + using var response = await PostAsync(Url(seed) + "?key=" + seed.Key, BODY); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + await ShouldHaveNoRequestsAsync(seed); + } + [Fact] public async Task A_bearer_key_takes_precedence_over_other_key_forms() { @@ -221,6 +227,28 @@ public async Task An_authenticated_malformed_or_non_object_body_is_400_and_write await ShouldHaveNoRequestsAsync(seed); } + [Fact] + public async Task An_unknown_slug_is_404_without_reading_the_body() + { + using var response = await PostAsync($"/api/ext/missing-{Guid.NewGuid():N}/run", new string('x', 1024 * 1024)); + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + } + + [Fact] + public async Task A_validated_endpoint_asks_for_retry_after_when_its_run_budget_is_exhausted() + { + var seed = await SeedAsync(); + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow; i++) + { + using var admitted = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + admitted.StatusCode.ShouldBe(HttpStatusCode.Accepted); + } + + using var refused = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + refused.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + refused.Headers.RetryAfter!.Delta.ShouldBe(ExternalTriggerRunAdmission.Window); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -277,6 +305,24 @@ public async Task An_admission_refusal_is_202_with_a_null_run_id() (await RunsAsync(seed)).ShouldBeEmpty(); } + [Fact] + public async Task A_refusal_is_audited_once_per_trigger_reason_and_day() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); + + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key)) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key)) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "first")) + using (await PostAsync(Url(seed), BODY, apiKey: seed.Key, idempotencyKey: "second")) + { + var rows = await new TenantHarness(factory).DbAsync(db => db.WorkflowRunRequest.AsNoTracking() + .Where(r => r.TeamId == seed.TeamId && r.Status == WorkflowRunRequestStatus.Rejected) + .Select(r => r.IdempotencyKey).ToArrayAsync()); + rows.ShouldHaveSingleItem().ShouldStartWith("refused:"); + } + } + private static string Url(Seed seed) => $"/api/ext/{seed.Slug}/run"; private async Task PostAsync(string url, string body, string? authorization = null, string? apiKey = null, string? idempotencyKey = null) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs index 3180621b0..84ffdb12c 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs @@ -23,8 +23,10 @@ public void The_ext_route_is_anonymous_bounded_and_rate_limited_like_webhook_ing controller.GetCustomAttribute().ShouldNotBeNull(); controller.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("api/ext"); var method = controller.GetMethod("Run").ShouldNotBeNull(); - method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{slug}/run"); + method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{slug:length(1,64)}/run"); ((IRequestSizeLimitMetadata)method.GetCustomAttribute().ShouldNotBeNull()).MaxRequestBodySize.ShouldBe(WebhooksController.MaxDeliveryBodyBytes); + method.GetParameters().ShouldNotContain(p => p.Name == "key" && p.GetCustomAttributes().Any(), + "a credential in the query string lands in access logs; the route admits headers only"); var policy = method.GetCustomAttribute() ?? controller.GetCustomAttribute(); policy.ShouldNotBeNull().PolicyName.ShouldBe(WebhookIngressRateLimitExtension.PolicyName); } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs index 53c9b7517..ac5271eab 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -199,6 +199,23 @@ public async Task Two_deliveries_without_an_idempotency_key_are_two_runs() (await RunsAsync(seed)).Length.ShouldBe(2); } + [Fact] + public async Task An_authenticated_delivery_cannot_exceed_the_per_trigger_run_budget() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(b => b.RegisterInstance(new ExternalTriggerRunAdmission()).As().SingleInstance()); + var ingress = scope.Resolve(); + var admitted = 0; + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow + 1; i++) + { + var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall(seed.Slug, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + if (outcome.Kind == ExternalTriggerOutcomeKind.Accepted) admitted++; + } + + admitted.ShouldBe(ExternalTriggerRunAdmission.PermitsPerWindow); + (await RunsAsync(seed)).Length.ShouldBe(ExternalTriggerRunAdmission.PermitsPerWindow); + } + [Fact] public async Task The_same_idempotency_key_twice_starts_one_run_and_returns_the_first_run_id() { @@ -223,7 +240,7 @@ public async Task Concurrent_deliveries_of_one_key_start_exactly_one_run() } [Fact] - public async Task A_recreated_activation_is_resolved_fresh_even_in_the_same_scope() + public async Task A_recreated_activation_is_resolved_fresh_and_the_trigger_still_deduplicates() { var seed = await SeedAsync(); using var scope = fixture.BeginScope(); @@ -239,8 +256,8 @@ public async Task A_recreated_activation_is_resolved_fresh_even_in_the_same_scop await db.SaveChangesAsync(); } var outcome = await ingress.ReceiveAsync(call, CancellationToken.None); - outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); - (await RunsAsync(seed)).Single(r => r.Id == outcome.RunId).RunRequest.ActivationId.ShouldBe(replacement); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + (await RunsAsync(seed)).ShouldHaveSingleItem().RunRequest.ActivationId.ShouldBe(seed.ActivationId); } [Theory] @@ -286,7 +303,8 @@ public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_aud audit.SourceType.ShouldBe("trigger.external"); audit.Error.ShouldNotBeNull(); audit.Error!.ShouldContain(reason); - audit.IdempotencyKey.ShouldBe($"rejected:{seed.Slug}:refused"); + audit.IdempotencyKey.ShouldStartWith($"refused:"); + audit.IdempotencyKey.ShouldContain($":{reason}:"); } [Fact] diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index cf73c3718..1490fa61e 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -325,6 +325,29 @@ public async Task An_underivable_slug_is_refused_without_creating_a_workflow(str (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); } + [Fact] + public async Task An_unsupported_schema_keyword_is_refused_at_save_without_creating_a_workflow() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var config = JsonSerializer.SerializeToElement(new + { + slug = "unsupported-schema-" + Guid.NewGuid().ToString("N"), + inputSchema = new { type = "object", properties = new { service = new { type = "string", pattern = "^a" } } }, + }); + var definition = Definition("unused") with + { + Nodes = Definition("unused").Nodes.Select(n => n.Id == "start" ? n with { Config = config } : n).ToArray(), + }; + + var error = await Record.ExceptionAsync(() => CreateAsync(scope, definition)); + + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Unprocessable); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + } + [Fact] public async Task An_underivable_rename_is_refused_and_keeps_the_old_endpoint() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs index 4042a2efe..ca83be858 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowRoundTripFlowTests.cs @@ -459,6 +459,7 @@ public async Task GetByRef_returns_null_for_a_ref_that_matches_nothing(string id [InlineData("Node Manifests", "node-manifests-2")] // shadows api/workflows/node-manifests [InlineData("System Variables", "system-variables-2")] // shadows api/workflows/system-variables [InlineData("Decisions", "decisions-2")] // shadows api/workflows/decisions + [InlineData("External Triggers", "external-triggers-2")] // shadows api/workflows/external-triggers public async Task Create_forces_a_suffix_when_the_slug_would_shadow_a_literal_route(string name, string expectedSlug) { // A workflow slugged to a literal sibling GET route (runs / node-manifests / system-variables / decisions) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs index bbdafcc58..58275d815 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/WorkflowSlugBackfillTests.cs @@ -76,6 +76,25 @@ await db.Database.ExecuteSqlRawAsync( await tx.RollbackAsync(); } + [Fact] + public async Task Migration_0252_frees_an_existing_external_triggers_slug() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(_fixture); + Guid id; + using (var scope = _fixture.BeginScopeAs(userId, teamId, Roles.Admin)) + id = await scope.Resolve().Send(Create("temp")); + + using var verify = _fixture.BeginScope(); + var db = verify.Resolve(); + await using var tx = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlRawAsync("UPDATE workflow SET slug = 'external-triggers' WHERE id = {0}", id); + + await db.Database.ExecuteSqlRawAsync(Migration0252); + + (await db.Workflow.AsNoTracking().SingleAsync(w => w.Id == id)).Slug.ShouldBe("external-triggers-2"); + await tx.RollbackAsync(); + } + private static CreateWorkflowCommand Create(string name) => new() { Name = name, @@ -114,4 +133,32 @@ OR EXISTS (SELECT 1 FROM workflow w UPDATE workflow SET slug = candidate WHERE id = r.id; END LOOP; END $$;"; + + private const string Migration0252 = @" +WITH reserved AS ( + SELECT id, team_id, slug + FROM workflow + WHERE slug = 'external-triggers' AND deleted_date IS NULL +), +candidates AS ( + SELECT r.id, r.team_id, r.slug, n + FROM reserved r + CROSS JOIN LATERAL generate_series(2, 1000) AS n + WHERE NOT EXISTS ( + SELECT 1 + FROM workflow w + WHERE w.team_id = r.team_id + AND w.slug = 'external-triggers-' || n::text + AND w.deleted_date IS NULL + ) +) +UPDATE workflow w +SET slug = 'external-triggers-' || c.n::text +FROM candidates c +WHERE w.id = c.id + AND c.n = ( + SELECT MIN(c2.n) + FROM candidates c2 + WHERE c2.id = c.id + );"; } diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs index a0c65b6ac..0772d4086 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerIdempotencyTests.cs @@ -25,7 +25,7 @@ public class ExternalTriggerIdempotencyTests public void A_maximum_length_key_passes() => ExternalTriggerIdempotency.IsWellFormed(new string('a', 200)).ShouldBeTrue(); [Fact] - public void The_key_scopes_to_the_activation_so_two_triggers_never_share_it() + public void The_key_scopes_to_the_trigger_so_two_triggers_never_share_it() { var a = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("11111111-1111-1111-1111-111111111111")); var b = ExternalTriggerIdempotency.KeyFor("evt-1", Guid.Parse("22222222-2222-2222-2222-222222222222")); diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs index d82217a85..7efe6e715 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -132,5 +132,101 @@ public void An_empty_type_union_is_refused_with_a_usable_error() errors.ShouldHaveSingleItem().ShouldContain("the schema declares none"); } - private static JsonElement Schema(string json) => JsonDocument.Parse(json).RootElement.Clone(); + [Fact] + public void An_unknown_type_keyword_is_refused_instead_of_silently_matching() + { + var errors = ExternalTriggerPayloadValidator.ValidateSchema(Schema("""{ "type": "sting" }""")); + errors.ShouldContain(e => e.Contains("sting") && e.Contains("not supported")); + } + + [Fact] + public void Validation_stops_after_a_bounded_number_of_errors() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("[" + string.Join(",", Enumerable.Repeat("1", 100)) + "]"), + Schema("""{ "type": "array", "items": { "type": "string" } }""")); + errors.Count.ShouldBe(ExternalTriggerPayloadValidator.MaxErrors); + } + + [Fact] + public void An_excessively_long_integer_exponent_is_refused_without_parsing_it() + { + var exponent = new string('9', 100_000); + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("1e" + exponent), + Schema("""{ "type": "integer" }""")); + errors.ShouldContain(e => e.Contains("number") && e.Contains("too long")); + } + + [Fact] + public void A_number_postgres_cannot_represent_is_refused() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("1e200000"), + Schema("""{ "type": "object" }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_is_refused_without_reading_an_invalid_string() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"\\uD800\""), + Schema("""{ "type": "string", "maxLength": 10 }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_does_not_turn_enum_validation_into_a_server_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("\"\\uD800\""), + Schema("""{ "type": "string", "enum": ["ok"] }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Fact] + public void An_unpaired_surrogate_nested_in_an_enum_value_does_not_escape_as_a_server_error() + { + var errors = ExternalTriggerPayloadValidator.Validate( + Schema("""[{ "text": "\uD800" }]"""), + Schema("""{ "type": "array", "items": { "type": "object", "enum": [{ "text": "ok" }] } }""")); + errors.ShouldContain(e => e.Contains("not representable")); + } + + [Theory] + [InlineData("""{ "type": "object", "properties": { "x": { "pattern": "^a" } } }""")] + [InlineData("""{ "type": "object", "properties": { "x": { "oneOf": [{ "type": "string" }] } } }""")] + [InlineData("""{ "type": "object", "properties": { "x": { "$ref": "#/$defs/x" } } }""")] + [InlineData("""{ "type": "object", "additionalProperties": { "type": "string", "pattern": "^a" } }""")] + public void Unsupported_schema_keywords_are_refused_at_save_validation(string schema) + { + var errors = ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema)); + errors.ShouldContain(e => e.Contains("not supported")); + } + + [Fact] + public void A_schema_deeper_than_the_limit_is_refused() + { + var schema = """{ "type": "object", "properties": { "x": """ + + string.Concat(Enumerable.Repeat("""{ "type": "array", "items": """, ExternalTriggerPayloadValidator.MaxSchemaDepth + 1)) + + """{ "type": "string" }""" + + string.Concat(Enumerable.Repeat(" }", ExternalTriggerPayloadValidator.MaxSchemaDepth + 1)) + + """ } }"""; + ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema, maxDepth: 256)).ShouldContain(e => e.Contains("too deep")); + } + + [Fact] + public void A_payload_deeper_than_the_limit_is_refused_before_representability_recursion() + { + var body = string.Concat(Enumerable.Repeat("[", ExternalTriggerPayloadValidator.MaxSchemaDepth + 2)) + + string.Concat(Enumerable.Repeat("]", ExternalTriggerPayloadValidator.MaxSchemaDepth + 2)); + var errors = ExternalTriggerPayloadValidator.Validate( + Schema(body, maxDepth: 256), + Schema("""{ "type": "array" }""")); + errors.ShouldContain(e => e.Contains("too deep")); + } + + private static JsonElement Schema(string json, int maxDepth = 64) => + JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = maxDepth }).RootElement.Clone(); } diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx index f3e4895c2..5c1511faa 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.test.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -65,8 +65,10 @@ describe("ExternalTriggerInspector", () => { fireEvent.click(screen.getByRole("button", { name: /copy key/i })); await waitFor(() => expect(writeText).toHaveBeenCalledWith("ext_secret")); fireEvent.click(screen.getByRole("button", { name: /copy curl/i })); - await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/prod-alerts/run?key=ext_secret"))); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/prod-alerts/run"))); expect(writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST")); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining("Authorization: Bearer ext_secret")); + expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining("?key=")); }); it("requires confirmation before rotating and warns that the old key stops working immediately", async () => { diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx index 2c368738e..907c263fb 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -44,7 +44,7 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig const error = reveal.error ?? rotate.error ?? rename.error; const endpoint = currentSlug ? `/api/ext/${encodeURIComponent(currentSlug)}/run` : null; const fullUrl = endpoint ? externalTriggerUrl(endpoint) : ""; - const curl = key ? `curl -X POST '${fullUrl}?key=${encodeURIComponent(key)}' -H 'Content-Type: application/json' -d '{}'` : null; + const curl = key ? `curl -X POST '${fullUrl}' -H 'Authorization: Bearer ${key}' -H 'Content-Type: application/json' -d '{}'` : null; const changeFields = (next: WorkflowVariable[]) => { const unappliedMetadata: string[] = []; From 0003bcf2996586eaef7745adfc8ef0d931d5e34d Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:08:16 +0800 Subject: [PATCH 23/26] Classify external trigger admission refusals --- .../Controllers/ExternalTriggersController.cs | 4 +- .../ExternalTriggerCommandHandlers.cs | 2 + .../ExternalTriggerIngress.cs | 4 +- .../IExternalTriggerIngress.cs | 2 +- .../ExternalTriggerDeliveryOutcome.cs | 8 ++-- .../ExternalTriggerEndpointE2ETests.cs | 41 +++++++++++++++++-- .../ExternalTriggerIngressFlowTests.cs | 9 ++-- 7 files changed, 55 insertions(+), 15 deletions(-) diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs index 455c7aa9c..e72227844 100644 --- a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -14,7 +14,7 @@ namespace CodeSpace.Api.Controllers; /// /// Anonymous ingress: the per-node key is the only credential, not a user or team session. Slugs are enumerable; /// every unavailable endpoint therefore has the same 404, revealing none of its deletion or activation state. -/// Accepted calls remain 2xx even when admission cannot stage a run, so an unfixable refusal does not invite retries. +/// Capacity admission answers 429 with Retry-After; deterministic authority or completion-policy refusals answer 403 or 422. /// Payload rejection still answers 400, with the ingress's errors rather than a second validation contract here. /// [ApiController] @@ -59,6 +59,8 @@ public async Task Run([FromRoute] string slug, CancellationToken { ExternalTriggerDeliveryKind.NotFound => NotFound(), ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(), + ExternalTriggerDeliveryKind.Forbidden => StatusCode(StatusCodes.Status403Forbidden, new { errors = outcome.Errors }), + ExternalTriggerDeliveryKind.Unprocessable => StatusCode(StatusCodes.Status422UnprocessableEntity, new { errors = outcome.Errors }), ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), ExternalTriggerDeliveryKind.RateLimited => RateLimited(), ExternalTriggerDeliveryKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs index 9d5c375b2..7b1fefecd 100644 --- a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -68,6 +68,8 @@ public async Task Handle(ReceiveExternalTriggerC ExternalTriggerOutcomeKind.Deduplicated => ExternalTriggerDeliveryKind.Deduplicated, ExternalTriggerOutcomeKind.NotFound => ExternalTriggerDeliveryKind.NotFound, ExternalTriggerOutcomeKind.Unauthorized => ExternalTriggerDeliveryKind.Unauthorized, + ExternalTriggerOutcomeKind.Forbidden => ExternalTriggerDeliveryKind.Forbidden, + ExternalTriggerOutcomeKind.Unprocessable => ExternalTriggerDeliveryKind.Unprocessable, ExternalTriggerOutcomeKind.InvalidPayload => ExternalTriggerDeliveryKind.InvalidPayload, ExternalTriggerOutcomeKind.RateLimited => ExternalTriggerDeliveryKind.RateLimited, _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs index 9dce027ab..a7c2b3022 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -126,12 +126,12 @@ public async Task ReceiveAsync(ExternalTriggerCall call, catch (AgentAuthorityDeniedException ex) { await AuditRefusalAsync(verification, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); - return Outcome(ExternalTriggerOutcomeKind.Accepted); + return Outcome(ExternalTriggerOutcomeKind.Forbidden, errors: [ex.Message]); } catch (CompletionAdmissionRefusedException ex) { await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); - return Outcome(ExternalTriggerOutcomeKind.Accepted); + return Outcome(ExternalTriggerOutcomeKind.Unprocessable, errors: [ex.Message]); } if (runId == Guid.Empty) diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs index a0297d986..5b8841ba2 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs @@ -26,4 +26,4 @@ public sealed record ExternalTriggerCall(string Slug, string? PresentedKey, stri public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors); -public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, InvalidPayload, RateLimited } +public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, Forbidden, Unprocessable, InvalidPayload, RateLimited } diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs index 20ecfa318..5a40fd9ee 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs @@ -2,9 +2,9 @@ namespace CodeSpace.Messages.Dtos.Workflows; /// /// What an anonymous external trigger delivery produced. Mirrors the ingress's own outcome so the -/// controller can map it to a status without the outcome type having to cross from CodeSpace.Core: -/// is 202 even when admission refused to stage a run, -/// and only carries a run id the caller already has. +/// controller can map it to a status without the outcome type having to cross from CodeSpace.Core. +/// A staged run is ; a deterministic admission +/// refusal is or . /// public sealed record ExternalTriggerDeliveryOutcome { @@ -23,6 +23,8 @@ public enum ExternalTriggerDeliveryKind Deduplicated, NotFound, Unauthorized, + Forbidden, + Unprocessable, InvalidPayload, RateLimited, } diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index 7fa4891de..d05c3937a 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -5,8 +5,10 @@ using CodeSpace.Api.Controllers; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows; using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -293,15 +295,46 @@ public async Task A_deduplicated_delivery_answers_200_with_the_first_run_id() } [Fact] - public async Task An_admission_refusal_is_202_with_a_null_run_id() + public async Task An_authority_refusal_is_403_without_a_run() { var seed = await SeedAsync(); await new TenantHarness(factory).DbAsync(db => db.TeamMembership.Where(m => m.TeamId == seed.TeamId).ExecuteDeleteAsync()); using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); - response.StatusCode.ShouldBe(HttpStatusCode.Accepted); + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); var payload = await response.Content.ReadFromJsonAsync(); - payload.GetProperty("runId").ValueKind.ShouldBe(JsonValueKind.Null); - payload.GetProperty("deduplicated").GetBoolean().ShouldBeFalse(); + payload.GetProperty("errors").EnumerateArray().ShouldNotBeEmpty(); + (await RunsAsync(seed)).ShouldBeEmpty(); + } + + [Fact] + public async Task A_completion_policy_refusal_is_422_without_a_run() + { + var seed = await SeedAsync(); + await new TenantHarness(factory).DbAsync(async db => + { + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var graph = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + var definition = JsonSerializer.Serialize(graph with { CompletionMode = WorkflowDefinition.CompletionModeEnforced }, WorkflowJson.Options); + workflow.LatestVersion = 2; + workflow.DefinitionJson = definition; + db.WorkflowVersion.Add(new WorkflowVersion + { + WorkflowId = seed.WorkflowId, + Version = 2, + DefinitionJson = definition, + DefinitionHash = "legacy", + CommittedAt = DateTimeOffset.UtcNow, + CreatedDate = DateTimeOffset.UtcNow, + CreatedBy = seed.UserId, + }); + await db.SaveChangesAsync(); + return true; + }); + + using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); + response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("errors").EnumerateArray().ShouldNotBeEmpty(); (await RunsAsync(seed)).ShouldBeEmpty(); } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs index ac5271eab..79a0999d1 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -270,9 +270,10 @@ public async Task A_missing_or_non_object_schema_imposes_no_extra_constraints(st } [Theory] - [InlineData(false, "agent.authority_denied")] - [InlineData(true, "completion_admission_refused")] - public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_audited(bool completion, string reason) + [InlineData(false, "agent.authority_denied", ExternalTriggerOutcomeKind.Forbidden)] + [InlineData(true, "completion_admission_refused", ExternalTriggerOutcomeKind.Unprocessable)] + public async Task A_deterministic_admission_refusal_is_classified_without_a_run_and_durably_audited( + bool completion, string reason, ExternalTriggerOutcomeKind expectedKind) { var seed = await SeedAsync(); using (var scope = fixture.BeginScope()) @@ -293,7 +294,7 @@ public async Task An_admission_refusal_is_accepted_without_a_run_and_durably_aud for (var i = 0; i < 2; i++) { var outcome = await ReceiveAsync(seed, idempotencyKey: "refused"); - outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + outcome.Kind.ShouldBe(expectedKind); outcome.RunId.ShouldBe(Guid.Empty); } (await RunsAsync(seed)).ShouldBeEmpty(); From 03c9cba0b692c3a5b6e3134afa73786c51175668 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:34 +0800 Subject: [PATCH 24/26] Allow anonymous external trigger verification --- .../Architecture/RequestAuthorizationInventoryTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs index 88bd22b59..34897ec9c 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs @@ -36,6 +36,7 @@ public class RequestAuthorizationInventoryTests ["ReceiveWebhookCommand"] = "provider-signed inbound; proof is the HMAC verified against the per-webhook secret.", ["ReceiveConnectionWebhookCommand"] = "the same inbound, from a group/organization hook; proof is the HMAC verified against THAT hook's own secret, never a repository's.", ["ReceiveExternalTriggerCommand"] = "an anonymous external trigger delivery; proof is the per-node key, checked against the trigger row's key digest.", + ["VerifyExternalTriggerCommand"] = "the anonymous identity check before an external trigger body is read; proof is the same per-node key, checked against the trigger row's key digest.", ["ResumeWorkflowCallbackCommand"] = "proof is the single-use high-entropy callback token in the URL.", ["WebhookExistsQuery"] = "asked before a delivery's body is read; it answers only whether the hook id names a hook, which the delivery's own 404 already tells the caller.", ["CompleteCredentialOAuthCommand"] = "the provider redirect carries no JWT; proof is the one-time OAuthPendingState row together with the nonce cookie only the starting browser holds. Without it nothing is consumed: the callback only hands the code to the SPA's completion page, where CompleteCredentialOAuthAsInitiatorCommand requires the signed-in starter.", From 5ed1097e1f34cae77cc26e5bb660dfb4673df155 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:40:49 +0800 Subject: [PATCH 25/26] Harden external trigger ingress contracts --- .../Controllers/ExternalTriggersController.cs | 47 +++-- .../WorkflowExternalTriggersController.cs | 20 +- .../WebhookIngressRateLimitExtension.cs | 11 +- .../Http/BoundedJsonBodyReader.cs | 41 ++++ .../ExternalTriggerCommandHandlers.cs | 28 +-- ...istWorkflowExternalTriggersQueryHandler.cs | 2 +- .../TransactionCompletionCallbacks.cs | 30 +++ .../Transactional/TransactionalBehavior.cs | 15 +- .../0251_workflow_external_trigger.sql | 2 - .../Entities/WorkflowExternalTrigger.cs | 1 - .../WorkflowExternalTriggerConfiguration.cs | 2 - .../ExternalTriggerDefinitionSync.cs | 18 +- ...nalTriggerIdempotencyIntegrityException.cs | 9 + .../ExternalTriggerIngress.cs | 142 ++++++++++--- .../ExternalTriggerInputProjection.cs | 9 +- .../ExternalTriggerJsonEquality.cs | 59 ++++++ .../ExternalTriggerPayloadValidator.cs | 107 +++++++--- .../ExternalTriggerRunAdmission.cs | 65 ++++-- .../ExternalTriggers/ExternalTriggerSlug.cs | 11 - .../ExternalTriggerSlugConflictException.cs | 10 - .../ExternalTriggers/ExternalTriggerStore.cs | 62 ++---- .../ExternalTriggerVerificationTicket.cs | 10 + .../IExternalTriggerIngress.cs | 6 +- .../ExternalTriggers/IExternalTriggerStore.cs | 7 +- .../Nodes/Builtin/TriggerExternalNode.cs | 2 - .../Services/Workflows/WorkflowService.cs | 7 +- .../ReceiveExternalTriggerCommand.cs | 15 +- .../Workflows/RenameExternalTriggerCommand.cs | 14 -- .../RevealExternalTriggerKeyCommand.cs | 2 +- .../RotateExternalTriggerKeyCommand.cs | 2 +- .../ExternalTriggerDeliveryOutcome.cs | 1 + .../WorkflowExternalTriggerSummary.cs | 2 +- .../Failures/FailureCodes.cs | 3 + .../Workflows/VerifyExternalTriggerQuery.cs | 13 ++ .../ExternalTriggerEndpointE2ETests.cs | 99 ++++++--- .../WebhookIngressSourceLimitE2ETests.cs | 1 + .../TransactionalBehaviorPostCommitTests.cs | 27 +++ .../ExternalTriggerIngressContractTests.cs | 128 ++++++++++-- .../ExternalTriggerIngressFlowTests.cs | 170 +++++++++++++-- .../ExternalTriggerManagementFlowTests.cs | 195 +++++++++++------- .../ExternalTriggerSchemaContractFlowTests.cs | 106 ++++++++++ .../ExternalTriggerStorageMigrationTests.cs | 32 ++- .../Architecture/FailureTaxonomyTests.cs | 2 + .../RequestAuthorizationInventoryTests.cs | 2 +- .../ScopedTransactionInventoryTests.cs | 2 + .../ExternalTriggerInputProjectionTests.cs | 13 +- .../ExternalTriggerJsonEqualityTests.cs | 38 ++++ .../ExternalTriggerPayloadValidatorTests.cs | 90 ++++++++ .../ExternalTriggerRunAdmissionTests.cs | 47 +++++ .../Workflows/ExternalTriggerSlugTests.cs | 31 --- .../Workflows/TriggerExternalNodeTests.cs | 5 +- frontend/src/api/workflows.ts | 14 +- .../workflows/external-trigger-editor.tsx | 4 +- .../external-trigger-inspector.test.tsx | 82 +++----- .../workflows/external-trigger-inspector.tsx | 64 ++---- frontend/src/hooks/use-workflows.ts | 12 +- .../external-trigger-schema-contract.test.ts | 27 +++ .../src/lib/external-trigger-slug.test.ts | 29 --- frontend/src/lib/external-trigger-slug.ts | 10 - 59 files changed, 1413 insertions(+), 592 deletions(-) create mode 100644 backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs create mode 100644 backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs delete mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs delete mode 100644 backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs create mode 100644 backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs delete mode 100644 backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs create mode 100644 frontend/src/lib/external-trigger-schema-contract.test.ts delete mode 100644 frontend/src/lib/external-trigger-slug.test.ts delete mode 100644 frontend/src/lib/external-trigger-slug.ts diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs index e72227844..c15eb4c35 100644 --- a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs @@ -2,20 +2,19 @@ using CodeSpace.Api.Http; using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; using CodeSpace.Messages.Dtos.Workflows; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using static CodeSpace.Api.Controllers.WebhooksController; +using CodeSpace.Messages.Failures; +using System.Text.Json; namespace CodeSpace.Api.Controllers; /// -/// Anonymous ingress: the per-node key is the only credential, not a user or team session. Slugs are enumerable; -/// every unavailable endpoint therefore has the same 404, revealing none of its deletion or activation state. -/// Capacity admission answers 429 with Retry-After; deterministic authority or completion-policy refusals answer 403 or 422. -/// Payload rejection still answers 400, with the ingress's errors rather than a second validation contract here. +/// Anonymous ingress authenticates before reading the bounded body or opening the receive transaction. /// [ApiController] [Route("api/ext")] @@ -27,44 +26,50 @@ public class ExternalTriggersController : ControllerBase public ExternalTriggersController(IMediator mediator) { _mediator = mediator; } - [HttpPost("{slug:length(1,64)}/run")] - [RequestSizeLimit(MaxDeliveryBodyBytes)] - public async Task Run([FromRoute] string slug, CancellationToken cancellationToken) + [HttpPost("{triggerId:guid}/run")] + [RequestSizeLimit(BoundedJsonBodyReader.MaxBytes + 1)] + public async Task Run([FromRoute] Guid triggerId, CancellationToken cancellationToken) { var authorization = Request.Headers.Authorization.ToString(); var bearer = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? authorization[7..].Trim() : null; var apiKey = Request.Headers["X-Api-Key"].ToString(); var presentedKey = !string.IsNullOrEmpty(bearer) ? bearer : !string.IsNullOrEmpty(apiKey) ? apiKey : null; - var verification = await _mediator.Send(new VerifyExternalTriggerCommand { Slug = slug, PresentedKey = presentedKey }, cancellationToken).ConfigureAwait(false); + var verification = await _mediator.Send(new VerifyExternalTriggerQuery { TriggerId = triggerId, PresentedKey = presentedKey }, cancellationToken).ConfigureAwait(false); if (verification.Kind is ExternalTriggerDeliveryKind.NotFound or ExternalTriggerDeliveryKind.Unauthorized) - return verification.Kind == ExternalTriggerDeliveryKind.NotFound ? NotFound() : Unauthorized(); + return Unauthorized(new { code = FailureCodes.Unauthorized, message = "Invalid external trigger credentials." }); - var body = await Request.ReadBoundedBodyAsync(MaxDeliveryBodyBytes, cancellationToken).ConfigureAwait(false); - if (body == null) return StatusCode(StatusCodes.Status413PayloadTooLarge); + JsonDocument document; + try { document = await BoundedJsonBodyReader.ReadAsync(Request, cancellationToken).ConfigureAwait(false); } + catch (BoundedJsonBodyReader.BodyTooLargeException) + { return StatusCode(StatusCodes.Status413PayloadTooLarge, new { code = FailureCodes.ExternalTriggerBodyTooLarge, message = "Request body exceeds 1048576 bytes." }); } + catch (JsonException) + { return BadRequest(new { code = FailureCodes.InvalidRequest, message = "Invalid external trigger input.", errors = new[] { "$: body must be a JSON object" } }); } + using var body = document; var idempotencyHeader = Request.Headers["Idempotency-Key"].ToString(); var idempotencyKey = string.IsNullOrWhiteSpace(idempotencyHeader) ? null : idempotencyHeader; var idempotencyRejected = idempotencyKey != null && !ExternalTriggerIdempotency.IsWellFormed(idempotencyKey); var outcome = await _mediator.Send(new ReceiveExternalTriggerCommand { - Slug = slug, + TriggerId = triggerId, PresentedKey = presentedKey, IdempotencyKey = idempotencyKey, IdempotencyRejected = idempotencyRejected, - RawBody = body, + Body = body.RootElement, }, cancellationToken).ConfigureAwait(false); return outcome.Kind switch { - ExternalTriggerDeliveryKind.NotFound => NotFound(), - ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(), - ExternalTriggerDeliveryKind.Forbidden => StatusCode(StatusCodes.Status403Forbidden, new { errors = outcome.Errors }), - ExternalTriggerDeliveryKind.Unprocessable => StatusCode(StatusCodes.Status422UnprocessableEntity, new { errors = outcome.Errors }), - ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { errors = outcome.Errors }), + ExternalTriggerDeliveryKind.NotFound => NotFound(new { code = FailureCodes.NotFound, message = "External trigger is unavailable." }), + ExternalTriggerDeliveryKind.Unauthorized => Unauthorized(new { code = FailureCodes.Unauthorized, message = "Invalid external trigger credentials." }), + ExternalTriggerDeliveryKind.Forbidden => StatusCode(StatusCodes.Status403Forbidden, new { code = FailureCodes.Forbidden, message = "External trigger execution is forbidden." }), + ExternalTriggerDeliveryKind.Unprocessable => StatusCode(StatusCodes.Status422UnprocessableEntity, new { code = outcome.Code ?? FailureCodes.ExternalTriggerCompletionRefused, + message = outcome.Code == FailureCodes.ExternalTriggerIdempotencyConflict ? "Idempotency key was already used with different content." : "External trigger execution was refused." }), + ExternalTriggerDeliveryKind.InvalidPayload => BadRequest(new { code = FailureCodes.InvalidRequest, message = "Invalid external trigger input.", errors = outcome.Errors }), ExternalTriggerDeliveryKind.RateLimited => RateLimited(), ExternalTriggerDeliveryKind.Deduplicated => Ok(new { runId = outcome.RunId, deduplicated = true }), - ExternalTriggerDeliveryKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId == Guid.Empty ? (Guid?)null : outcome.RunId, deduplicated = false }), + ExternalTriggerDeliveryKind.Accepted => StatusCode(StatusCodes.Status202Accepted, new { runId = outcome.RunId, deduplicated = false }), _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"), }; } @@ -72,6 +77,6 @@ public async Task Run([FromRoute] string slug, CancellationToken private IActionResult RateLimited() { Response.Headers.RetryAfter = ((int)ExternalTriggerRunAdmission.Window.TotalSeconds).ToString(); - return StatusCode(StatusCodes.Status429TooManyRequests, new { retryAfterSeconds = (int)ExternalTriggerRunAdmission.Window.TotalSeconds }); + return StatusCode(StatusCodes.Status429TooManyRequests, new { code = FailureCodes.RateLimited, message = "External trigger run capacity is exhausted." }); } } diff --git a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs index b06e39b27..9695dc8bd 100644 --- a/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs +++ b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs @@ -16,24 +16,20 @@ public async Task List([FromQuery] ListWorkflowExternalTriggersQu return result == null ? NotFound() : Ok(result); } - [HttpGet("{slug}/key")] - public async Task Reveal([FromRoute] string slug, CancellationToken cancellationToken) + [HttpGet("{triggerId:guid}/key")] + public async Task Reveal([FromRoute] Guid triggerId, CancellationToken cancellationToken) { - var key = await mediator.Send(new RevealExternalTriggerKeyCommand { Slug = slug }, cancellationToken).ConfigureAwait(false); + Response.Headers.CacheControl = "private, no-store"; + var key = await mediator.Send(new RevealExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false); return Ok(new { key }); } - [HttpPost("{slug}/rotate-key")] - public async Task Rotate([FromRoute] string slug, CancellationToken cancellationToken) + [HttpPost("{triggerId:guid}/rotate-key")] + public async Task Rotate([FromRoute] Guid triggerId, CancellationToken cancellationToken) { - var key = await mediator.Send(new RotateExternalTriggerKeyCommand { Slug = slug }, cancellationToken).ConfigureAwait(false); + Response.Headers.CacheControl = "private, no-store"; + var key = await mediator.Send(new RotateExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false); return Ok(new { key }); } - [HttpPost("{slug}/rename")] - public async Task Rename([FromRoute] string slug, [FromBody] RenameExternalTriggerCommand command, CancellationToken cancellationToken) - { - await mediator.Send(command with { Slug = slug }, cancellationToken).ConfigureAwait(false); - return NoContent(); - } } diff --git a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs index 74a034b87..f1db6aabb 100644 --- a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs +++ b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs @@ -1,5 +1,7 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; +using CodeSpace.Messages.Failures; +using System.Globalization; namespace CodeSpace.Api.Extensions; @@ -37,7 +39,7 @@ public static class WebhookIngressRateLimitExtension public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); /// The route values that name what a request is addressed to, in the order the ingress routes declare them. - private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token", "slug" }; + private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token", "triggerId" }; /// The one partition every non-ingress request shares in the global limiter — no limit. Never an address. private const string NotIngress = "not-ingress"; @@ -47,6 +49,12 @@ public static void AddWebhookIngressRateLimit(this IServiceCollection services) services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, ct) => + { + var retry = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var delay) ? delay : Window; + context.HttpContext.Response.Headers.RetryAfter = Math.Max(0, (int)Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + await context.HttpContext.Response.WriteAsJsonAsync(new { code = FailureCodes.RateLimited, message = "Request rate limit exceeded." }, ct).ConfigureAwait(false); + }; options.GlobalLimiter = PartitionedRateLimiter.Create(SourcePartition); options.AddPolicy(PolicyName, context => FixedWindow(PartitionKey(context), PermitsPerWindow)); }); @@ -70,6 +78,7 @@ private static RateLimitPartition FixedWindow(string key, int permits) = internal static string PartitionKey(HttpContext context) { var target = TargetRouteValues.Select(name => context.GetRouteValue(name)?.ToString()).FirstOrDefault(value => !string.IsNullOrEmpty(value)) ?? "-"; + if (context.GetRouteValue("triggerId") is not null && Guid.TryParse(target, out var triggerId)) target = triggerId.ToString("N"); return $"{target}|{SourcePartitionKey(context)}"; } diff --git a/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs b/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs new file mode 100644 index 000000000..89c110c93 --- /dev/null +++ b/backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace CodeSpace.Api.Http; + +public static class BoundedJsonBodyReader +{ + public const int MaxBytes = 1_048_576; + + public static async Task ReadAsync(HttpRequest request, CancellationToken ct) + { + using var stream = new LimitedStream(request.Body); + try { return await JsonDocument.ParseAsync(stream, new JsonDocumentOptions { MaxDepth = 64 }, ct).ConfigureAwait(false); } + catch (BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge) + { throw new BodyTooLargeException(); } + } + + public sealed class BodyTooLargeException : IOException; + + private sealed class LimitedStream(Stream source) : Stream + { + private int _read; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => _read; set => throw new NotSupportedException(); } + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + var read = await source.ReadAsync(buffer[..Math.Min(buffer.Length, MaxBytes + 1 - _read)], ct).ConfigureAwait(false); + _read += read; + if (_read > MaxBytes) throw new BodyTooLargeException(); + return read; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs index 7b1fefecd..74f9bb8dd 100644 --- a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs @@ -2,6 +2,7 @@ using CodeSpace.Core.Services.Identity; using CodeSpace.Core.Services.Workflows.ExternalTriggers; using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; using CodeSpace.Messages.Dtos.Workflows; using MediatR; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,7 @@ public sealed class RevealExternalTriggerKeyCommandHandler( { public async Task Handle(RevealExternalTriggerKeyCommand request, CancellationToken cancellationToken) { - var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) + var trigger = await store.FindAsync(request.TriggerId, team.Id!.Value, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException("External trigger not found in this team."); var ciphertext = await db.WorkflowExternalTrigger.AsNoTracking() .Where(t => t.Id == trigger.Id && t.TeamId == team.Id.Value && t.DeletedDate == null) @@ -33,31 +34,19 @@ public sealed class RotateExternalTriggerKeyCommandHandler(IExternalTriggerStore { public async Task Handle(RotateExternalTriggerKeyCommand request, CancellationToken cancellationToken) { - var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) + var trigger = await store.FindAsync(request.TriggerId, team.Id!.Value, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException("External trigger not found in this team."); return await store.RotateKeyAsync(trigger.Id, team.Id.Value, actor.Id!.Value, cancellationToken).ConfigureAwait(false); } } -public sealed class RenameExternalTriggerCommandHandler(IExternalTriggerStore store, ICurrentTeam team, ICurrentUser actor) - : IRequestHandler -{ - public async Task Handle(RenameExternalTriggerCommand request, CancellationToken cancellationToken) - { - var trigger = await store.FindAsync(request.Slug, team.Id!.Value, cancellationToken).ConfigureAwait(false) - ?? throw new KeyNotFoundException("External trigger not found in this team."); - await store.RenameAsync(trigger.Id, team.Id.Value, request.DesiredSlug, actor.Id!.Value, cancellationToken).ConfigureAwait(false); - return Unit.Value; - } -} - public sealed class ReceiveExternalTriggerCommandHandler(IExternalTriggerIngress ingress) : IRequestHandler { public async Task Handle(ReceiveExternalTriggerCommand request, CancellationToken cancellationToken) { var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall( - request.Slug, request.PresentedKey, request.IdempotencyKey, request.RawBody, request.IdempotencyRejected), + request.TriggerId, request.PresentedKey, request.IdempotencyKey, string.Empty, request.IdempotencyRejected, request.Body), cancellationToken).ConfigureAwait(false); return new ExternalTriggerDeliveryOutcome @@ -76,16 +65,17 @@ public async Task Handle(ReceiveExternalTriggerC }, RunId = outcome.RunId, Errors = outcome.Errors, + Code = outcome.Code, }; } } -public sealed class VerifyExternalTriggerCommandHandler(IExternalTriggerIngress ingress) - : IRequestHandler +public sealed class VerifyExternalTriggerQueryHandler(IExternalTriggerIngress ingress) + : IRequestHandler { - public async Task Handle(VerifyExternalTriggerCommand request, CancellationToken cancellationToken) + public async Task Handle(VerifyExternalTriggerQuery request, CancellationToken cancellationToken) { - var verification = await ingress.VerifyAsync(request.Slug, request.PresentedKey, cancellationToken).ConfigureAwait(false); + var verification = await ingress.VerifyAsync(request.TriggerId, request.PresentedKey, cancellationToken).ConfigureAwait(false); var kind = verification.Kind switch { ExternalTriggerOutcomeKind.Accepted => ExternalTriggerDeliveryKind.Accepted, diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs index a95122c6e..3b9ee2901 100644 --- a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs @@ -19,6 +19,6 @@ public sealed class ListWorkflowExternalTriggersQueryHandler(CodeSpaceDbContext var triggers = await store.ListForWorkflowAsync(request.WorkflowId, teamId, cancellationToken).ConfigureAwait(false); return new WorkflowExternalTriggersResult(triggers - .Select(t => new WorkflowExternalTriggerSummary(t.Id, t.WorkflowId, t.NodeId, t.Slug)).ToArray()); + .Select(t => new WorkflowExternalTriggerSummary(t.Id, t.WorkflowId, t.NodeId)).ToArray()); } } diff --git a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs new file mode 100644 index 000000000..1552826ef --- /dev/null +++ b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionCompletionCallbacks.cs @@ -0,0 +1,30 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Middlewares.Transactional; + +public sealed class TransactionCompletionCallbacks : IScopedDependency, IDisposable +{ + private readonly List> _committed = []; + private readonly List _rolledBack = []; + + public void RegisterAfterCommit(Func callback) => _committed.Add(callback); + public void RegisterAfterRollback(Action callback) => _rolledBack.Add(callback); + + public async Task RunAfterCommitAsync(CancellationToken ct) + { + var callbacks = _committed.ToArray(); + _committed.Clear(); + _rolledBack.Clear(); + foreach (var callback in callbacks) await callback(ct).ConfigureAwait(false); + } + + public void RunAfterRollback() + { + var callbacks = _rolledBack.ToArray(); + _committed.Clear(); + _rolledBack.Clear(); + foreach (var callback in callbacks) callback(); + } + + public void Dispose() => RunAfterRollback(); +} diff --git a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs index df8aed67f..ca6f1c7c3 100644 --- a/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs +++ b/backend/src/CodeSpace.Core/Middlewares/Transactional/TransactionalBehavior.cs @@ -10,12 +10,14 @@ public sealed class TransactionalBehavior : IPipelineBehavi private readonly CodeSpaceDbContext _dbContext; private readonly IPostCommitActions _postCommit; private readonly ILogger> _logger; + private readonly TransactionCompletionCallbacks? _completion; - public TransactionalBehavior(CodeSpaceDbContext dbContext, IPostCommitActions postCommit, ILogger> logger) + public TransactionalBehavior(CodeSpaceDbContext dbContext, IPostCommitActions postCommit, ILogger> logger, TransactionCompletionCallbacks? completion = null) { _dbContext = dbContext; _postCommit = postCommit; _logger = logger; + _completion = completion; } public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) @@ -28,6 +30,7 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate, IAuditable public Guid TeamId { get; set; } public Guid WorkflowId { get; set; } public string NodeId { get; set; } = default!; - public string Slug { get; set; } = default!; public string KeyDigest { get; set; } = default!; public string KeyCiphertext { get; set; } = default!; public DateTimeOffset CreatedDate { get; set; } diff --git a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs index 7f9a3672d..ce5c2f663 100644 --- a/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs +++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs @@ -13,7 +13,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(t => t.TeamId).HasColumnName("team_id"); builder.Property(t => t.WorkflowId).HasColumnName("workflow_id"); builder.Property(t => t.NodeId).HasColumnName("node_id").HasMaxLength(64); - builder.Property(t => t.Slug).HasMaxLength(64); builder.Property(t => t.KeyDigest).HasColumnName("key_digest"); builder.Property(t => t.KeyCiphertext).HasColumnName("key_ciphertext"); builder.Property(t => t.CreatedDate).HasColumnName("created_date"); @@ -22,7 +21,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(t => t.LastModifiedBy).HasColumnName("last_modified_by"); builder.Property(t => t.DeletedDate).HasColumnName("deleted_date"); - builder.HasIndex(t => t.Slug).IsUnique().HasFilter("deleted_date IS NULL").HasDatabaseName("uq_wet_slug"); builder.HasIndex(t => t.KeyDigest).HasDatabaseName("idx_wet_key_digest"); builder.HasIndex(t => new { t.WorkflowId, t.NodeId }).HasDatabaseName("idx_wet_workflow_node"); builder.HasIndex(t => t.TeamId).HasDatabaseName("idx_wet_team"); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs index a421ca93e..4ccb6d7c7 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs @@ -8,6 +8,15 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public sealed class ExternalTriggerDefinitionSync(CodeSpaceDbContext db, IExternalTriggerStore store) : IScopedDependency { + public static WorkflowDefinition Normalize(WorkflowDefinition definition) => definition with + { + Nodes = definition.Nodes.Select(node => node.TypeKey == ExternalTriggerSlug.TypeKey + && node.Config.ValueKind == JsonValueKind.Object && node.Config.TryGetProperty("slug", out _) + ? node with { Config = JsonSerializer.SerializeToElement(node.Config.EnumerateObject() + .Where(property => property.Name != "slug").ToDictionary(property => property.Name, property => property.Value)) } + : node).ToArray(), + }; + /// /// Save-time schema validation runs before any workflow row is written, so an unsupported keyword cannot leave /// a half-created workflow behind. @@ -37,14 +46,7 @@ public async Task SyncAsync(Guid workflowId, Guid teamId, WorkflowDefinition def row.KeyCiphertext = string.Empty; } - // Release the partial unique slug before a replacement claims it. The command transaction keeps both atomic. - if (retired.Count != 0) await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - foreach (var node in nodes) - { - var slug = node.Config.ValueKind == JsonValueKind.Object && node.Config.TryGetProperty("slug", out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() : null; - await store.EnsureForNodeAsync(workflowId, teamId, node.Id, slug, actorId, cancellationToken).ConfigureAwait(false); - } + await store.EnsureForNodeAsync(workflowId, teamId, node.Id, actorId, cancellationToken).ConfigureAwait(false); } } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs new file mode 100644 index 000000000..7a3c4d134 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotencyIntegrityException.cs @@ -0,0 +1,9 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +public sealed class ExternalTriggerIdempotencyIntegrityException(string detail) : Exception(detail), IFailure +{ + public FailureKind Kind => FailureKind.Internal; + public string Code => FailureCodes.Internal; +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs index a7c2b3022..f529d7ef4 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs @@ -31,13 +31,31 @@ public sealed class ExternalTriggerIngress( IPostCommitActions postCommit, IIngestionAuditor auditor, ExternalTriggerRunAdmission admission, + ExternalTriggerVerificationContext verificationContext, + TransactionCompletionCallbacks completion, ILogger logger) : IExternalTriggerIngress { - public async Task VerifyAsync(string slug, string? presentedKey, CancellationToken ct) + public async Task VerifyAsync(Guid triggerId, string? presentedKey, CancellationToken ct) { + verificationContext.Ticket = null; var trigger = await db.WorkflowExternalTrigger.AsNoTracking() - .SingleOrDefaultAsync(t => t.Slug == slug && t.DeletedDate == null, ct).ConfigureAwait(false); - if (trigger is null) return Verification(ExternalTriggerOutcomeKind.NotFound); + .Where(t => t.Id == triggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (trigger is null || !KeyMatches(presentedKey, trigger.KeyDigest)) + return Verification(ExternalTriggerOutcomeKind.Unauthorized); + + verificationContext.Ticket = trigger; + return Verification(ExternalTriggerOutcomeKind.Accepted); + } + + private async Task ResolveAsync(ExternalTriggerVerificationTicket ticket, CancellationToken ct) + { + var trigger = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == ticket.TriggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (trigger is null || trigger != ticket) return Verification(ExternalTriggerOutcomeKind.Unauthorized); var activations = await db.WorkflowActivation.AsNoTracking().Include(a => a.Workflow) .Where(a => a.WorkflowId == trigger.WorkflowId && a.TypeKey == ExternalTriggerSlug.TypeKey @@ -60,17 +78,13 @@ public async Task VerifyAsync(string slug, string? if (nodes is not { Count: 1 } || nodes[0].Id != trigger.NodeId) return Verification(ExternalTriggerOutcomeKind.NotFound); var config = nodes[0].Config; - if (presentedKey is null || !CryptographicOperations.FixedTimeEquals( - Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(presentedKey)), Encoding.ASCII.GetBytes(trigger.KeyDigest))) - return Verification(ExternalTriggerOutcomeKind.Unauthorized); - var schema = config.ValueKind == JsonValueKind.Object && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object ? declared : default; return new ExternalTriggerVerification( ExternalTriggerOutcomeKind.Accepted, - trigger.Id, + trigger.TriggerId, activation.Id, activation.WorkflowId, activation.Workflow.TeamId, @@ -81,23 +95,75 @@ public async Task VerifyAsync(string slug, string? public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct) { - var verification = await VerifyAsync(call.Slug, call.PresentedKey, ct).ConfigureAwait(false); - if (verification.Kind != ExternalTriggerOutcomeKind.Accepted) - return Outcome(verification.Kind); - + var ticket = verificationContext.Ticket; + verificationContext.Ticket = null; + if (ticket is null || ticket.TriggerId != call.TriggerId || !KeyMatches(call.PresentedKey, ticket.KeyDigest)) + { + var authentication = await VerifyAsync(call.TriggerId, call.PresentedKey, ct).ConfigureAwait(false); + ticket = verificationContext.Ticket; + verificationContext.Ticket = null; + if (authentication.Kind != ExternalTriggerOutcomeKind.Accepted || ticket is null) + return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + } if (call.IdempotencyRejected) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: [$"Idempotency-Key must be at most {ExternalTriggerIdempotency.MaxLength} characters from [A-Za-z0-9._~-]"]); - JsonDocument body; - try { body = JsonDocument.Parse(call.RawBody); } + JsonDocument? body; + try { body = call.ParsedBody.HasValue ? null : JsonDocument.Parse(call.RawBody); } catch (JsonException) { return InvalidBody(); } using (body) { - if (body.RootElement.ValueKind != JsonValueKind.Object) return InvalidBody(); + var payload = call.ParsedBody ?? body!.RootElement; + if (payload.ValueKind != JsonValueKind.Object) return InvalidBody(); + var errors = ExternalTriggerPayloadValidator.ValidatePayload(payload); + if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); + + if (db.Database.CurrentTransaction is not null) + return await ReceiveInTransactionAsync(call, ticket, payload, ct).ConfigureAwait(false); + + var checkpoint = postCommit.CreateCheckpoint(); + await using var transaction = await ScopedTransaction.OwnOrJoinAsync(db.Database, ct).ConfigureAwait(false); + ExternalTriggerOutcome outcome; + try + { + outcome = await ReceiveInTransactionAsync(call, ticket, payload, ct).ConfigureAwait(false); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + try { await db.Database.CurrentTransaction!.RollbackAsync(CancellationToken.None).ConfigureAwait(false); } + finally { postCommit.RollbackTo(checkpoint); completion.RunAfterRollback(); } + throw; + } + await transaction.DisposeAsync().ConfigureAwait(false); + await completion.RunAfterCommitAsync(CancellationToken.None).ConfigureAwait(false); + await postCommit.RunAllAsync(CancellationToken.None).ConfigureAwait(false); + return outcome; + } + } + + private async Task ReceiveInTransactionAsync(ExternalTriggerCall call, ExternalTriggerVerificationTicket ticket, JsonElement payload, CancellationToken ct) + { + // Saves and ingestion take workflow before trigger; body reads never hold these locks. + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {ticket.WorkflowId} FOR UPDATE", ct).ConfigureAwait(false); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow_external_trigger WHERE id = {ticket.TriggerId} FOR UPDATE", ct).ConfigureAwait(false); + var current = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == ticket.TriggerId && t.DeletedDate == null) + .Select(t => new ExternalTriggerVerificationTicket(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.KeyDigest)) + .SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (current != ticket) return Outcome(ExternalTriggerOutcomeKind.Unauthorized); + + var idempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, ticket.TriggerId); + var existing = await FindReplayAsync(idempotencyKey, payload, ct).ConfigureAwait(false); + if (existing is not null) return existing; + + var verification = await ResolveAsync(ticket, ct).ConfigureAwait(false); + if (verification.Kind != ExternalTriggerOutcomeKind.Accepted) return Outcome(verification.Kind); using var fallback = JsonDocument.Parse("""{"type":"object"}"""); var schema = verification.InputSchema.ValueKind == JsonValueKind.Object ? verification.InputSchema : fallback.RootElement; - var errors = ExternalTriggerPayloadValidator.Validate(body.RootElement, schema); + var errors = ExternalTriggerPayloadValidator.Validate(payload, schema); if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors); var envelope = new RunSourceEnvelope @@ -111,11 +177,12 @@ public async Task ReceiveAsync(ExternalTriggerCall call, CreatedBy = SystemUsers.SeederId, ActivationId = verification.ActivationId, ActivationSnapshotJson = verification.ActivationSnapshotJson, - NormalizedPayloadJson = body.RootElement.GetRawText(), - IdempotencyKey = call.IdempotencyKey is null ? null : ExternalTriggerIdempotency.KeyFor(call.IdempotencyKey, verification.TriggerId), + NormalizedPayloadJson = payload.GetRawText(), + IdempotencyKey = idempotencyKey, }; - if (!admission.TryAcquire(verification.TriggerId)) + var reservation = admission.Reserve(verification.TriggerId); + if (reservation is null) { await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.TriggerRateLimited, "per-trigger run admission refused", ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.RateLimited); @@ -125,29 +192,49 @@ public async Task ReceiveAsync(ExternalTriggerCall call, try { runId = await runStarter.StartAsync(envelope, ct).ConfigureAwait(false); } catch (AgentAuthorityDeniedException ex) { + reservation.Release(); await AuditRefusalAsync(verification, call, ex.Code, ex.Reason, ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.Forbidden, errors: [ex.Message]); } catch (CompletionAdmissionRefusedException ex) { + reservation.Release(); await AuditRefusalAsync(verification, call, WorkflowRunRequestRejectionReasons.CompletionAdmissionRefused, ex.Message, ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.Unprocessable, errors: [ex.Message]); } + catch { reservation.Release(); throw; } if (runId == Guid.Empty) { - var existing = envelope.IdempotencyKey is null ? null : await db.WorkflowRun.AsNoTracking() - .Where(r => r.RunRequest.IdempotencyKey == envelope.IdempotencyKey) - .Select(r => (Guid?)r.Id).SingleOrDefaultAsync(ct).ConfigureAwait(false); - if (existing.HasValue) return Outcome(ExternalTriggerOutcomeKind.Deduplicated, existing.Value); - logger.LogWarning("External trigger {Slug} was deduplicated but its run could not be found", call.Slug); - return Outcome(ExternalTriggerOutcomeKind.Accepted); + reservation.Release(); + return await FindReplayAsync(idempotencyKey, payload, ct).ConfigureAwait(false) + ?? throw new ExternalTriggerIdempotencyIntegrityException("External trigger duplicate has no durable run."); } + if (db.Database.CurrentTransaction is null) reservation.Commit(); + else + { + completion.RegisterAfterCommit(_ => { reservation.Commit(); return Task.CompletedTask; }); + completion.RegisterAfterRollback(reservation.Release); + } await db.SaveChangesAsync(ct).ConfigureAwait(false); await postCommit.RunAfterCommitAsync(token => runDispatcher.DispatchAsync(runId, token), ct).ConfigureAwait(false); return Outcome(ExternalTriggerOutcomeKind.Accepted, runId); - } + } + + private async Task FindReplayAsync(string? key, JsonElement payload, CancellationToken ct) + { + if (key is null) return null; + var request = await db.WorkflowRunRequest.AsNoTracking().Where(r => r.IdempotencyKey == key) + .Select(r => new { r.Id, r.NormalizedPayloadJson }).SingleOrDefaultAsync(ct).ConfigureAwait(false); + if (request is null) return null; + var runId = await db.WorkflowRun.AsNoTracking().Where(r => r.RunRequestId == request.Id) + .Select(r => (Guid?)r.Id).SingleOrDefaultAsync(ct).ConfigureAwait(false) + ?? throw new ExternalTriggerIdempotencyIntegrityException($"External trigger accepted request {request.Id} has no durable run."); + using var stored = JsonDocument.Parse(request.NormalizedPayloadJson); + return ExternalTriggerJsonEquality.AreEquivalent(payload, stored.RootElement) + ? Outcome(ExternalTriggerOutcomeKind.Deduplicated, runId) + : new ExternalTriggerOutcome(ExternalTriggerOutcomeKind.Unprocessable, Guid.Empty, [], CodeSpace.Messages.Failures.FailureCodes.ExternalTriggerIdempotencyConflict); } private async Task AuditRefusalAsync(ExternalTriggerVerification verification, ExternalTriggerCall call, string reason, string detail, CancellationToken ct) @@ -170,6 +257,9 @@ private static string BuildRefusalWindowKey(Guid triggerId, string reason, DateT private static ExternalTriggerVerification Verification(ExternalTriggerOutcomeKind kind) => new(kind, Guid.Empty, Guid.Empty, Guid.Empty, Guid.Empty, 0, string.Empty, default); + private static bool KeyMatches(string? presentedKey, string digest) => presentedKey is not null + && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(ExternalTriggerKeySealer.Digest(presentedKey)), Encoding.ASCII.GetBytes(digest)); + private static ExternalTriggerOutcome InvalidBody() => Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: ["$: body must be a JSON object"]); private static ExternalTriggerOutcome Outcome(ExternalTriggerOutcomeKind kind, Guid runId = default, IReadOnlyList? errors = null) => new(kind, runId, errors ?? []); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs index fa992672b..2e9046b61 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs @@ -27,15 +27,16 @@ public static IReadOnlyList Apply(IReadOnlyList + AreEquivalent(left, right, int.MaxValue, out _, out _); + + public static bool AreEquivalent(JsonElement left, JsonElement right, int maxNodes, out int nodesVisited, out bool exhausted) + { + var remaining = maxNodes; + exhausted = false; + var equivalent = Compare(left, right, ref remaining, ref exhausted); + nodesVisited = maxNodes - remaining; + return equivalent; + } + + private static bool Compare(JsonElement left, JsonElement right, ref int remaining, ref bool exhausted) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (left.ValueKind != right.ValueKind) return false; + + if (left.ValueKind == JsonValueKind.Object) + { + // Index once so member order does not turn a bounded comparison into repeated object scans. + var properties = new Dictionary(StringComparer.Ordinal); + foreach (var property in right.EnumerateObject()) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (!properties.TryAdd(property.Name, property.Value)) return false; + } + foreach (var property in left.EnumerateObject()) + { + if (remaining == 0) { exhausted = true; return false; } + remaining--; + if (!properties.Remove(property.Name, out var other) + || !Compare(property.Value, other, ref remaining, ref exhausted)) return false; + } + return properties.Count == 0; + } + + if (left.ValueKind == JsonValueKind.Array) + { + var leftItems = left.EnumerateArray(); + var rightItems = right.EnumerateArray(); + if (left.GetArrayLength() != right.GetArrayLength()) return false; + using var leftEnumerator = leftItems.GetEnumerator(); + using var rightEnumerator = rightItems.GetEnumerator(); + while (leftEnumerator.MoveNext() && rightEnumerator.MoveNext()) + if (!Compare(leftEnumerator.Current, rightEnumerator.Current, ref remaining, ref exhausted)) return false; + return true; + } + + return JsonElement.DeepEquals(left, right); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs index 19b337bbe..3e5019a9a 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using System.Numerics; using System.Globalization; +using System.Text; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; @@ -14,6 +14,7 @@ public static class ExternalTriggerPayloadValidator public const int MaxNumberTokenLength = 256; public const int MaxSchemaDepth = 64; public const int MaxNodes = 10_000; + public const int MaxEnumComparisonNodes = 100_000; /// /// The schema keywords the HTTP contract actually enforces. A keyword outside this set is refused at save time @@ -27,9 +28,15 @@ public static class ExternalTriggerPayloadValidator public static IReadOnlyList Validate(JsonElement body, JsonElement inputSchema) { var errors = new List(); - var budget = new ValidationBudget(); - ValidateRepresentable(body, errors, budget, depth: 0); - ValidateNode(body, inputSchema, "$", errors, budget, depth: 0); + ValidateRepresentable(body, errors, new ValidationBudget(), depth: 0); + if (errors.Count == 0) ValidateNode(body, inputSchema, "$", errors, new ValidationBudget(), depth: 0); + return errors; + } + + public static IReadOnlyList ValidatePayload(JsonElement body) + { + var errors = new List(); + ValidateRepresentable(body, errors, new ValidationBudget(), depth: 0); return errors; } @@ -37,8 +44,12 @@ public static IReadOnlyList Validate(JsonElement body, JsonElement input public static IReadOnlyList ValidateSchema(JsonElement schema) { var errors = new List(); - var budget = new ValidationBudget(); - ValidateSchemaNode(schema, "$", errors, budget, depth: 0); + ValidateRepresentable(schema, errors, new ValidationBudget(), depth: 0); + if (errors.Count != 0) return errors; + if (schema.ValueKind != JsonValueKind.Object || !schema.TryGetProperty("type", out var rootType) + || rootType.ValueKind != JsonValueKind.String || rootType.GetString() != "object") + errors.Add("$: schema root must declare type 'object'"); + ValidateSchemaNode(schema, "$", errors, new ValidationBudget(), depth: 0); return errors; } @@ -56,6 +67,7 @@ private static void ValidateNode(JsonElement value, JsonElement schema, string p budget.Exhausted = true; return; } + if (schema.ValueKind == JsonValueKind.False) { errors.Add($"{path}: value is not allowed"); return; } if (schema.ValueKind != JsonValueKind.Object) return; if (schema.TryGetProperty("type", out var type) && !TypeMatches(value, type)) @@ -64,9 +76,24 @@ private static void ValidateNode(JsonElement value, JsonElement schema, string p return; } - if (schema.TryGetProperty("enum", out var enumValues) && enumValues.ValueKind == JsonValueKind.Array && - !enumValues.EnumerateArray().Any(candidate => JsonValueEquals(value, candidate))) - errors.Add($"{path}: value '{DisplayValue(value)}' is not one of the allowed enum values"); + if (schema.TryGetProperty("enum", out var enumValues) && enumValues.ValueKind == JsonValueKind.Array) + { + var matched = false; + foreach (var candidate in enumValues.EnumerateArray()) + { + var remaining = MaxEnumComparisonNodes - budget.EnumComparisonNodes; + matched = ExternalTriggerJsonEquality.AreEquivalent(value, candidate, remaining, out var visited, out var exhausted); + budget.EnumComparisonNodes += visited; + if (exhausted) + { + errors.Add("$: enum comparison budget exceeded"); + budget.Exhausted = true; + return; + } + if (matched) break; + } + if (!matched) errors.Add($"{path}: value '{DisplayValue(value)}' is not one of the allowed enum values"); + } if (value.ValueKind == JsonValueKind.Object) { @@ -82,7 +109,7 @@ private static void ValidateNode(JsonElement value, JsonElement schema, string p } } else if (value.ValueKind == JsonValueKind.String && schema.TryGetProperty("maxLength", out var maxLength) && - maxLength.TryGetInt32(out var maximum) && TryGetString(value, out var text) && text.Length > maximum) + maxLength.TryGetInt32(out var maximum) && TryGetString(value, out var text) && text.EnumerateRunes().Count() > maximum) { errors.Add($"{path}: string exceeds maximum length {maximum}"); } @@ -111,13 +138,16 @@ private static void ValidateObject(JsonElement value, JsonElement schema, string ValidateNode(property.Value, propertySchema, $"{path}.{property.Name}", errors, budget, depth + 1); else if (closesObject) errors.Add($"{path}.{property.Name}: additional property is not allowed"); + else if (additionalProperties.ValueKind == JsonValueKind.Object) + ValidateNode(property.Value, additionalProperties, $"{path}.{property.Name}", errors, budget, depth + 1); } } private static void ValidateSchemaNode(JsonElement schema, string path, List errors, ValidationBudget budget, int depth) { if (errors.Count >= MaxErrors || budget.Exhausted) return; - if (schema.ValueKind != JsonValueKind.Object) return; + if (schema.ValueKind is JsonValueKind.True or JsonValueKind.False) return; + if (schema.ValueKind != JsonValueKind.Object) { errors.Add($"{path}: schema must be an object or boolean"); return; } if (depth > MaxSchemaDepth) { errors.Add($"{path}: schema is too deep (max {MaxSchemaDepth})"); @@ -132,8 +162,22 @@ private static void ValidateSchemaNode(JsonElement schema, string path, List= MaxErrors) return; + if (!SupportedSchemaKeywords.Contains(keyword.Name) && !keyword.Name.StartsWith("x-", StringComparison.Ordinal)) errors.Add($"{path}.{keyword.Name}: schema keyword '{keyword.Name}' is not supported"); + var value = keyword.Value; + var valid = keyword.Name switch + { + "required" => value.ValueKind == JsonValueKind.Array && value.EnumerateArray().All(v => v.ValueKind == JsonValueKind.String), + "enum" => value.ValueKind == JsonValueKind.Array && value.GetArrayLength() > 0, + "maxLength" => value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var maximum) && maximum >= 0, + "properties" => value.ValueKind == JsonValueKind.Object, + "items" => value.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False, + "additionalProperties" => value.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False, + "title" or "description" => value.ValueKind == JsonValueKind.String, + _ => true, + }; + if (!valid && errors.Count < MaxErrors) errors.Add($"{path}.{keyword.Name}: invalid schema keyword value"); } if (schema.TryGetProperty("type", out var type) && !TypeKeywordIsSupported(type)) @@ -156,7 +200,7 @@ private static void ValidateSchemaNode(JsonElement schema, string path, List type.ValueKind switch { JsonValueKind.String => IsSupportedType(type.GetString()!), - JsonValueKind.Array => type.EnumerateArray().All(candidate => candidate.ValueKind == JsonValueKind.String && IsSupportedType(candidate.GetString()!)), + JsonValueKind.Array => type.GetArrayLength() > 0 && type.EnumerateArray().All(candidate => candidate.ValueKind == JsonValueKind.String && IsSupportedType(candidate.GetString()!)), _ => false, }; @@ -204,12 +248,24 @@ private static void ValidateRepresentable(JsonElement value, List errors errors.Add("$: number is not representable"); break; case JsonValueKind.Object: + var names = new HashSet(StringComparer.Ordinal); foreach (var property in value.EnumerateObject()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) break; + string name; + try { name = property.Name; } + catch (InvalidOperationException) { errors.Add("$: property name is not representable"); continue; } + if (!IsWellFormedUtf16(name)) errors.Add("$: property name contains text that cannot be stored"); + if (!names.Add(name) && errors.Count < MaxErrors) errors.Add("$: duplicate property name is not allowed"); ValidateRepresentable(property.Value, errors, budget, depth + 1); + } break; case JsonValueKind.Array: foreach (var item in value.EnumerateArray()) + { + if (errors.Count >= MaxErrors || budget.Exhausted) break; ValidateRepresentable(item, errors, budget, depth + 1); + } break; } } @@ -219,9 +275,8 @@ private static bool TryGetRepresentableNumber(string raw) var exponentIndex = raw.IndexOfAny(['e', 'E']); if (exponentIndex >= 0) { - var exponentText = raw[(exponentIndex + 1)..]; - if (exponentText.StartsWith('+') || exponentText.StartsWith('-')) exponentText = exponentText[1..]; - if (exponentText.Length > 20) return false; + if (!int.TryParse(raw.AsSpan(exponentIndex + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var exponent) + || exponent is < -330 or > 330) return false; } return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number); @@ -238,6 +293,7 @@ private static bool IsWellFormedUtf16(string value) { for (var i = 0; i < value.Length; i++) { + if (value[i] == '\0') return false; if (char.IsHighSurrogate(value[i])) { if (i + 1 >= value.Length || !char.IsLowSurrogate(value[i + 1])) return false; @@ -280,7 +336,7 @@ private static bool IsInteger(string rawNumber) { var exponentIndex = rawNumber.IndexOfAny(['e', 'E']); var mantissa = exponentIndex < 0 ? rawNumber : rawNumber[..exponentIndex]; - var exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(rawNumber[(exponentIndex + 1)..], CultureInfo.InvariantCulture); + var exponent = exponentIndex < 0 ? 0 : int.Parse(rawNumber.AsSpan(exponentIndex + 1), CultureInfo.InvariantCulture); var decimalIndex = mantissa.IndexOf('.'); var fractionalDigits = decimalIndex < 0 ? 0 : mantissa.Length - decimalIndex - 1; var digits = mantissa.Replace(".", "", StringComparison.Ordinal).TrimStart('-'); @@ -289,24 +345,14 @@ private static bool IsInteger(string rawNumber) // JSON Schema treats 5.0 as an integer: accept a decimal token when its exact value has no fractional // part after applying the exponent. Checking token digits avoids double rounding away a non-zero fraction. - var remainingFractionalDigits = new BigInteger(fractionalDigits) - exponent; - if (remainingFractionalDigits <= BigInteger.Zero) return true; + var remainingFractionalDigits = fractionalDigits - exponent; + if (remainingFractionalDigits <= 0) return true; return remainingFractionalDigits <= digits.Length - significantDigits.Length; } private static bool JsonValueEquals(JsonElement left, JsonElement right) { - if (left.ValueKind != right.ValueKind) return false; - - return left.ValueKind switch - { - JsonValueKind.String => TryGetString(left, out var leftText) && TryGetString(right, out var rightText) - ? leftText == rightText - : RawTextEquals(left, right), - JsonValueKind.Number => left.TryGetDouble(out var leftNumber) && right.TryGetDouble(out var rightNumber) && leftNumber == rightNumber, - JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null => true, - _ => RawTextEquals(left, right), - }; + return JsonElement.DeepEquals(left, right); } private static string KindName(JsonValueKind kind) => kind switch @@ -329,6 +375,7 @@ private static bool RawTextEquals(JsonElement left, JsonElement right) private sealed class ValidationBudget { public int Nodes { get; set; } + public int EnumComparisonNodes { get; set; } public bool Exhausted { get; set; } } } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs index e14c925d1..78ef54b23 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs @@ -1,31 +1,70 @@ using System.Collections.Concurrent; -using System.Threading.RateLimiting; using CodeSpace.Core.DependencyInjection; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; /// -/// A budget on authenticated deliveries that can actually reach RunStarter. Request-rate limiting happens before -/// authentication and is keyed by the sender's address and target; it cannot express a leaked key repeatedly starting -/// runs. This limiter is process-local, so its effective ceiling scales with the number of API replicas. +/// Process-local budget; only durable new runs consume capacity. /// -public sealed class ExternalTriggerRunAdmission : ISingletonDependency, IDisposable +public sealed class ExternalTriggerRunAdmission(TimeProvider? timeProvider = null) : ISingletonDependency, IDisposable { public const int PermitsPerWindow = 60; public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); - private readonly ConcurrentDictionary _limiters = new(); + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + private readonly ConcurrentDictionary _windows = new(); - public bool TryAcquire(Guid triggerId) + public Reservation? Reserve(Guid triggerId) { - var limiter = _limiters.GetOrAdd(triggerId, _ => new FixedWindowRateLimiter( - new FixedWindowRateLimiterOptions { PermitLimit = PermitsPerWindow, Window = Window, QueueLimit = 0 })); - return limiter.AttemptAcquire(1).IsAcquired; + var state = _windows.GetOrAdd(triggerId, _ => new WindowState()); + lock (state) + { + var now = _clock.GetUtcNow(); + if (now >= state.Start + Window) + { + state.Start = now; + state.Generation++; + state.Successful = 0; + state.Reserved = 0; + } + if (state.Successful + state.Reserved >= PermitsPerWindow) return null; + state.Reserved++; + return new Reservation(state, state.Generation); + } } - public void Dispose() + public void Dispose() => _windows.Clear(); + + internal sealed class WindowState + { + public DateTimeOffset Start = DateTimeOffset.MinValue; + public long Generation; + public int Successful; + public int Reserved; + } + + public sealed class Reservation : IDisposable { - foreach (var limiter in _limiters.Values) limiter.Dispose(); - _limiters.Clear(); + private readonly WindowState _state; + private readonly long _generation; + private bool _completed; + + internal Reservation(WindowState state, long generation) { _state = state; _generation = generation; } + + public void Commit() => Complete(committed: true); + public void Release() => Complete(committed: false); + public void Dispose() => Release(); + + private void Complete(bool committed) + { + lock (_state) + { + if (_completed) return; + _completed = true; + if (_generation != _state.Generation) return; + _state.Reserved--; + if (committed) _state.Successful++; + } + } } } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs index e1e776f56..801326da8 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs @@ -1,17 +1,6 @@ -using CodeSpace.Core.Services.Slugs; - namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; -/// -/// The only slug derivation entry point delegates to , whose output is pinned by -/// golden-vector tests. If the result is empty (for example, all-Chinese or symbol-only input), callers must report -/// an error instead of silently deriving a fallback from an id; this slug becomes a permanent string in third-party configuration. -/// public static class ExternalTriggerSlug { public const string TypeKey = "trigger.external"; - - public static string Derive(string? authorInput) => Slug.Slugify(authorInput); - - public static bool IsUsable(string? derived) => !string.IsNullOrWhiteSpace(derived); } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs deleted file mode 100644 index 33e2fdb25..000000000 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlugConflictException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CodeSpace.Messages.Failures; - -namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; - -public sealed class ExternalTriggerSlugConflictException(string slug, Exception? innerException = null) - : Exception($"External trigger slug '{slug}' is already in use. Choose another slug.", innerException), IFailure -{ - public FailureKind Kind => FailureKind.Conflict; - public string Code => FailureCodes.DuplicateResource; -} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs index d29a29199..6f26d14a4 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs @@ -1,7 +1,6 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using Microsoft.EntityFrameworkCore; -using Npgsql; namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; @@ -11,22 +10,20 @@ public async Task> ListForWorkflowAsync(Guid await db.WorkflowExternalTrigger.AsNoTracking() .Where(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null) .OrderBy(t => t.NodeId) - .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.Slug)) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId)) .ToArrayAsync(cancellationToken).ConfigureAwait(false); - public async Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken) => + public async Task FindAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken) => await db.WorkflowExternalTrigger.AsNoTracking() - .Where(t => t.Slug == slug && t.TeamId == teamId && t.DeletedDate == null) - .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId, t.Slug)) + .Where(t => t.Id == triggerId && t.TeamId == teamId && t.DeletedDate == null) + .Select(t => new ExternalTriggerView(t.Id, t.TeamId, t.WorkflowId, t.NodeId)) .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); /// - /// The row owns the endpoint name and credential: a stale draft must not undo an explicit rename or rotation. /// Node identity survives activation replacement on every save; an activation id would retire a working credential. /// - public async Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken) + public async Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, Guid actorId, CancellationToken cancellationToken) { - var slug = DeriveRequiredSlug(desiredSlug, "slug"); var row = await db.WorkflowExternalTrigger.SingleOrDefaultAsync( t => t.WorkflowId == workflowId && t.TeamId == teamId && t.NodeId == nodeId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false); if (row is not null) return View(row); @@ -37,53 +34,36 @@ public async Task EnsureForNodeAsync(Guid workflowId, Guid var key = sealer.Mint(); row = new WorkflowExternalTrigger { - Id = Guid.NewGuid(), WorkflowId = workflowId, TeamId = teamId, NodeId = nodeId, Slug = slug, + Id = Guid.NewGuid(), WorkflowId = workflowId, TeamId = teamId, NodeId = nodeId, KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = actorId, LastModifiedBy = actorId, }; db.WorkflowExternalTrigger.Add(row); - await SaveAsync(slug, cancellationToken).ConfigureAwait(false); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return View(row); } public async Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken) { - var row = await LoadAsync(triggerId, teamId, cancellationToken).ConfigureAwait(false); + await using var transaction = await ScopedTransaction.OwnOrJoinAsync(db.Database, cancellationToken).ConfigureAwait(false); + var workflowId = await db.WorkflowExternalTrigger.AsNoTracking() + .Where(t => t.Id == triggerId && t.TeamId == teamId) + .Select(t => (Guid?)t.WorkflowId).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); + + await db.Database.ExecuteSqlInterpolatedAsync( + $"SELECT id FROM workflow WHERE id = {workflowId} AND team_id = {teamId} FOR UPDATE", cancellationToken).ConfigureAwait(false); + var row = await db.WorkflowExternalTrigger.FromSqlInterpolated( + $"SELECT * FROM workflow_external_trigger WHERE id = {triggerId} AND team_id = {teamId} AND deleted_date IS NULL FOR UPDATE") + .SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("External trigger not found in this team."); var key = sealer.Mint(); row.KeyDigest = key.Digest; row.KeyCiphertext = key.Ciphertext; row.LastModifiedBy = actorId; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return key.Key; } - public async Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken) - { - var row = await LoadAsync(triggerId, teamId, cancellationToken).ConfigureAwait(false); - row.Slug = DeriveRequiredSlug(desiredSlug, nameof(desiredSlug)); - row.LastModifiedBy = actorId; - await SaveAsync(row.Slug, cancellationToken).ConfigureAwait(false); - } - - private async Task LoadAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken) => - await db.WorkflowExternalTrigger.SingleOrDefaultAsync(t => t.Id == triggerId && t.TeamId == teamId && t.DeletedDate == null, cancellationToken).ConfigureAwait(false) - ?? throw new KeyNotFoundException("External trigger not found in this team."); - - private async Task SaveAsync(string slug, CancellationToken cancellationToken) - { - try { await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "uq_wet_slug" }) - { - throw new ExternalTriggerSlugConflictException(slug, ex); - } - } - - private static string DeriveRequiredSlug(string? desiredSlug, string parameterName) - { - var slug = ExternalTriggerSlug.Derive(desiredSlug); - if (!ExternalTriggerSlug.IsUsable(slug)) - throw new ArgumentException("The name you entered must derive into a non-empty external trigger slug. Include at least one ASCII letter, digit or underscore. Hyphens and underscores are allowed in the resulting slug.", parameterName); - return slug; - } - - private static ExternalTriggerView View(WorkflowExternalTrigger row) => new(row.Id, row.TeamId, row.WorkflowId, row.NodeId, row.Slug); + private static ExternalTriggerView View(WorkflowExternalTrigger row) => new(row.Id, row.TeamId, row.WorkflowId, row.NodeId); } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs new file mode 100644 index 000000000..a9682d76b --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerVerificationTicket.cs @@ -0,0 +1,10 @@ +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; + +internal sealed record ExternalTriggerVerificationTicket(Guid TriggerId, Guid TeamId, Guid WorkflowId, string NodeId, string KeyDigest); + +public sealed class ExternalTriggerVerificationContext : IScopedDependency +{ + internal ExternalTriggerVerificationTicket? Ticket { get; set; } +} diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs index 5b8841ba2..ea4063cd6 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs @@ -6,7 +6,7 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public interface IExternalTriggerIngress : IScopedDependency { /// Resolves and authenticates the endpoint before any body is read. - Task VerifyAsync(string slug, string? presentedKey, CancellationToken ct); + Task VerifyAsync(Guid triggerId, string? presentedKey, CancellationToken ct); Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct); } @@ -22,8 +22,8 @@ public sealed record ExternalTriggerVerification( string ActivationSnapshotJson, JsonElement InputSchema); -public sealed record ExternalTriggerCall(string Slug, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected); +public sealed record ExternalTriggerCall(Guid TriggerId, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected, JsonElement? ParsedBody = null); -public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors); +public sealed record ExternalTriggerOutcome(ExternalTriggerOutcomeKind Kind, Guid RunId, IReadOnlyList Errors, string? Code = null); public enum ExternalTriggerOutcomeKind { Accepted, Deduplicated, NotFound, Unauthorized, Forbidden, Unprocessable, InvalidPayload, RateLimited } diff --git a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs index f5be26731..eff194fce 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerStore.cs @@ -4,11 +4,10 @@ namespace CodeSpace.Core.Services.Workflows.ExternalTriggers; public interface IExternalTriggerStore : IScopedDependency { - Task FindAsync(string slug, Guid teamId, CancellationToken cancellationToken); + Task FindAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken); Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken); - Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, string? desiredSlug, Guid actorId, CancellationToken cancellationToken); + Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, Guid actorId, CancellationToken cancellationToken); Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken); - Task RenameAsync(Guid triggerId, Guid teamId, string desiredSlug, Guid actorId, CancellationToken cancellationToken); } -public sealed record ExternalTriggerView(Guid Id, Guid TeamId, Guid WorkflowId, string NodeId, string Slug); +public sealed record ExternalTriggerView(Guid Id, Guid TeamId, Guid WorkflowId, string NodeId); diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs index 5c9d70093..a5680c112 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs @@ -23,10 +23,8 @@ public sealed class TriggerExternalNode : INodeRuntime { "type": "object", "properties": { - "slug": { "type": "string" }, "inputSchema": { "type": "object" } }, - "required": ["slug"], "additionalProperties": false } """), diff --git a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs index 6afb89163..5a9c7275a 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs @@ -167,8 +167,9 @@ public async Task> ListAsync(Guid teamId, Cancell public async Task CreateAsync(Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, bool enabled, CancellationToken cancellationToken) { - definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; + definition = ExternalTriggerDefinitionSync.Normalize(definition); ExternalTriggerDefinitionSync.ValidateSchemas(definition); + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); @@ -229,11 +230,13 @@ public async Task CreateAsync(Guid teamId, string name, string? descriptio public async Task UpdateAsync(Guid workflowId, Guid teamId, string name, string? description, WorkflowDefinition definition, IReadOnlyList activations, CancellationToken cancellationToken) { - definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; + definition = ExternalTriggerDefinitionSync.Normalize(definition); ExternalTriggerDefinitionSync.ValidateSchemas(definition); + definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) }; EnsureValidDefinition(definition); await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false); + await _db.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {workflowId} AND team_id = {teamId} FOR UPDATE", cancellationToken).ConfigureAwait(false); var workflow = await LoadWorkflowAsync(workflowId, teamId, cancellationToken).ConfigureAwait(false) ?? throw new KeyNotFoundException($"Workflow {workflowId} not found in team {teamId}."); diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs index 06584270f..9e50ad0ab 100644 --- a/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs @@ -1,5 +1,6 @@ using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Mediation; +using System.Text.Json; namespace CodeSpace.Messages.Commands.Workflows; @@ -11,7 +12,7 @@ namespace CodeSpace.Messages.Commands.Workflows; /// public sealed record ReceiveExternalTriggerCommand : ICommand { - public required string Slug { get; init; } + public required Guid TriggerId { get; init; } /// The key as presented, already normalized from Bearer / X-Api-Key. Null when none was sent. public required string? PresentedKey { get; init; } @@ -23,15 +24,5 @@ public sealed record ReceiveExternalTriggerCommand : ICommandThe delivery body, already bounded by the endpoint's size limit. - public required string RawBody { get; init; } + public required JsonElement Body { get; init; } } - -/// Anonymous identity check only; the controller sends this before reading any request body. -public sealed record VerifyExternalTriggerCommand : ICommand -{ - public required string Slug { get; init; } - public required string? PresentedKey { get; init; } -} - -/// Wire-safe verification result, mirroring the ingress kind without exposing its internal identity record. -public sealed record ExternalTriggerVerificationOutcome(ExternalTriggerDeliveryKind Kind); diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs deleted file mode 100644 index 420cfbbd9..000000000 --- a/backend/src/CodeSpace.Messages/Commands/Workflows/RenameExternalTriggerCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CodeSpace.Messages.Authorization; -using CodeSpace.Messages.Constants; -using CodeSpace.Messages.Mediation; -using MediatR; - -namespace CodeSpace.Messages.Commands.Workflows; - -/// Retires the URL configured by existing callers, so renaming must be explicit rather than a draft-save side effect. -public sealed record RenameExternalTriggerCommand : ICommand, IRequireTeamPermission -{ - public string RequiredPermission => TeamPermissions.WorkflowsWrite; - public string Slug { get; init; } = string.Empty; - public required string DesiredSlug { get; init; } -} diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs index e91919a83..e155994e6 100644 --- a/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RevealExternalTriggerKeyCommand.cs @@ -8,5 +8,5 @@ namespace CodeSpace.Messages.Commands.Workflows; public sealed record RevealExternalTriggerKeyCommand : ICommand, IRequireTeamPermission { public string RequiredPermission => TeamPermissions.WorkflowsWrite; - public string Slug { get; init; } = string.Empty; + public required Guid TriggerId { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs index 114a263a0..020d3c2a4 100644 --- a/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Workflows/RotateExternalTriggerKeyCommand.cs @@ -8,5 +8,5 @@ namespace CodeSpace.Messages.Commands.Workflows; public sealed record RotateExternalTriggerKeyCommand : ICommand, IRequireTeamPermission { public string RequiredPermission => TeamPermissions.WorkflowsWrite; - public string Slug { get; init; } = string.Empty; + public required Guid TriggerId { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs index 5a40fd9ee..c1778df9d 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs @@ -15,6 +15,7 @@ public sealed record ExternalTriggerDeliveryOutcome /// Non-empty only on — the ingress's own messages. public required IReadOnlyList Errors { get; init; } + public string? Code { get; init; } } public enum ExternalTriggerDeliveryKind diff --git a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs index 13cc2eb1c..f0ae28ffd 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/WorkflowExternalTriggerSummary.cs @@ -3,6 +3,6 @@ namespace CodeSpace.Messages.Dtos.Workflows; /// /// Credentials are absent because keys are obtainable only through the permission-gated reveal endpoint. /// -public sealed record WorkflowExternalTriggerSummary(Guid Id, Guid WorkflowId, string NodeId, string Slug); +public sealed record WorkflowExternalTriggerSummary(Guid Id, Guid WorkflowId, string NodeId); public sealed record WorkflowExternalTriggersResult(IReadOnlyList Triggers); diff --git a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs index 05f6dd64d..a181ae6cb 100644 --- a/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs +++ b/backend/src/CodeSpace.Messages/Failures/FailureCodes.cs @@ -84,6 +84,9 @@ public static class FailureCodes // ── Workflows and runs ───────────────────────────────────────────────────────── public const string WorkflowDefinitionInvalid = "workflow_definition_invalid"; + public const string ExternalTriggerIdempotencyConflict = "external_trigger_idempotency_conflict"; + public const string ExternalTriggerCompletionRefused = "external_trigger_completion_refused"; + public const string ExternalTriggerBodyTooLarge = "external_trigger_body_too_large"; public const string TaskRouteConfirmationRequired = "task_route_confirmation_required"; public const string TaskRouteSnapshotMismatch = "task_route_snapshot_mismatch"; public const string WorkspaceUnresolvable = "workspace_unresolvable"; diff --git a/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs b/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs new file mode 100644 index 000000000..82130717b --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Workflows/VerifyExternalTriggerQuery.cs @@ -0,0 +1,13 @@ +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Workflows; + +/// Anonymous digest check before body reading, outside the command transaction pipeline. +public sealed record VerifyExternalTriggerQuery : IQuery +{ + public required Guid TriggerId { get; init; } + public required string? PresentedKey { get; init; } +} + +public sealed record ExternalTriggerVerificationOutcome(ExternalTriggerDeliveryKind Kind); diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs index d05c3937a..10a866cc5 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using CodeSpace.Api.Controllers; +using CodeSpace.Api.Extensions; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Workflows; @@ -21,7 +22,7 @@ namespace CodeSpace.E2ETests.Webhooks; public sealed class ExternalTriggerEndpointE2ETests(WebhookApiFactory factory) : IClassFixture { private const string BODY = """{"service":"a"}"""; - private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, string Slug, string Key); + private sealed record Seed(Guid UserId, Guid TeamId, Guid WorkflowId, Guid TriggerId, string Key); [Fact] public async Task Metadata_lists_only_public_live_fields_and_allows_viewers() @@ -37,11 +38,10 @@ public async Task Metadata_lists_only_public_live_fields_and_allows_viewers() var payload = await response.Content.ReadFromJsonAsync(); payload.EnumerateObject().Select(p => p.Name).ShouldBe(new[] { "triggers" }); var trigger = payload.GetProperty("triggers").EnumerateArray().ShouldHaveSingleItem(); - trigger.EnumerateObject().Select(p => p.Name).Order().ShouldBe(new[] { "id", "nodeId", "slug", "workflowId" }); - trigger.GetProperty("id").GetGuid().ShouldNotBe(Guid.Empty); + trigger.EnumerateObject().Select(p => p.Name).Order().ShouldBe(new[] { "id", "nodeId", "workflowId" }); + trigger.GetProperty("id").GetGuid().ShouldBe(seed.TriggerId); trigger.GetProperty("workflowId").GetGuid().ShouldBe(seed.WorkflowId); trigger.GetProperty("nodeId").GetString().ShouldBe("trigger"); - trigger.GetProperty("slug").GetString().ShouldBe(seed.Slug); } [Fact] @@ -71,57 +71,53 @@ await harness.DbAsync(db => db.WorkflowExternalTrigger.Where(t => t.WorkflowId = } [Fact] - public async Task Management_reveals_rotates_and_renames_the_saved_endpoint() + public async Task Management_reveals_and_rotates_without_changing_the_endpoint() { var seed = await SeedAsync(); var harness = new TenantHarness(factory); - using var reveal = await harness.SendAsync(HttpMethod.Get, $"/api/workflows/external-triggers/{seed.Slug}/key", seed.UserId, seed.TeamId); + using var reveal = await harness.SendAsync(HttpMethod.Get, $"/api/workflows/external-triggers/{seed.TriggerId}/key", seed.UserId, seed.TeamId); reveal.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(reveal)); + reveal.Headers.CacheControl!.Private.ShouldBeTrue(); + reveal.Headers.CacheControl.NoStore.ShouldBeTrue(); (await reveal.Content.ReadFromJsonAsync()).GetProperty("key").GetString().ShouldBe(seed.Key); - using var rotate = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.Slug}/rotate-key", seed.UserId, seed.TeamId); + using var rotate = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.TriggerId}/rotate-key", seed.UserId, seed.TeamId); rotate.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(rotate)); + rotate.Headers.CacheControl!.Private.ShouldBeTrue(); + rotate.Headers.CacheControl.NoStore.ShouldBeTrue(); var fresh = (await rotate.Content.ReadFromJsonAsync()).GetProperty("key").GetString(); fresh.ShouldNotBeNullOrEmpty(); fresh.ShouldNotBe(seed.Key); using var oldKey = await PostAsync(Url(seed), BODY, apiKey: seed.Key); oldKey.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); - var renamed = "prod-alerts-v2-" + Guid.NewGuid().ToString("N"); - using var rename = await harness.SendAsync(HttpMethod.Post, $"/api/workflows/external-triggers/{seed.Slug}/rename", seed.UserId, seed.TeamId, new { desiredSlug = renamed }); - rename.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(rename)); - using var oldUrl = await PostAsync(Url(seed), BODY, apiKey: fresh); - oldUrl.StatusCode.ShouldBe(HttpStatusCode.NotFound); - using var newUrl = await PostAsync($"/api/ext/{renamed}/run", BODY, apiKey: fresh); + using var newUrl = await PostAsync(Url(seed), BODY, apiKey: fresh); newUrl.StatusCode.ShouldBe(HttpStatusCode.Accepted); } [Theory] [InlineData("key", "GET")] [InlineData("rotate-key", "POST")] - [InlineData("rename", "POST")] public async Task Management_is_team_scoped_and_denies_viewers(string action, string method) { var seed = await SeedAsync(); var harness = new TenantHarness(factory); var viewer = await harness.AccountAsync(); await harness.AddMemberAsync(seed.TeamId, viewer, TeamRole.Viewer); - var url = $"/api/workflows/external-triggers/{seed.Slug}/{action}"; + var url = $"/api/workflows/external-triggers/{seed.TriggerId}/{action}"; using var denied = await harness.SendAsync(new HttpMethod(method), url, viewer, seed.TeamId, method == "POST" ? new { desiredSlug = "unused" } : null); denied.StatusCode.ShouldBe(HttpStatusCode.Forbidden); var other = await harness.TeamAsync((seed.UserId, TeamRole.Member)); using var absent = await harness.SendAsync(new HttpMethod(method), url, seed.UserId, other, method == "POST" ? new { desiredSlug = "unused" } : null); absent.StatusCode.ShouldBe(HttpStatusCode.NotFound); - using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers/{seed.Slug}/key"); + using var anonymous = await harness.Client().GetAsync($"/api/workflows/external-triggers/{seed.TriggerId}/key"); anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [Fact] - public async Task A_slug_conflict_answers_409_without_breaking_either_endpoint() + public async Task Independent_trigger_identities_have_independent_endpoints() { var first = await SeedAsync(); var other = await SeedAsync(); - using var conflict = await new TenantHarness(factory).SendAsync(HttpMethod.Post, - $"/api/workflows/external-triggers/{other.Slug}/rename", other.UserId, other.TeamId, new { desiredSlug = first.Slug }); - conflict.StatusCode.ShouldBe(HttpStatusCode.Conflict, await TenantHarness.DescribeAsync(conflict)); + first.TriggerId.ShouldNotBe(other.TriggerId); using var firstCall = await PostAsync(Url(first), BODY, apiKey: first.Key); using var otherCall = await PostAsync(Url(other), BODY, apiKey: other.Key); firstCall.StatusCode.ShouldBe(HttpStatusCode.Accepted); @@ -195,18 +191,49 @@ public async Task A_bearer_key_takes_precedence_over_other_key_forms() public async Task A_body_over_the_limit_is_413() { var seed = await SeedAsync(); - using var response = await PostAsync(Url(seed), new string('x', (int)WebhooksController.MaxDeliveryBodyBytes + 1), apiKey: seed.Key); + using var response = await PostAsync(Url(seed), new string('x', 1_048_577), apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.RequestEntityTooLarge); await ShouldHaveNoRequestsAsync(seed); } + [Theory] + [InlineData(0, HttpStatusCode.Accepted)] + [InlineData(1, HttpStatusCode.RequestEntityTooLarge)] + public async Task The_actual_body_byte_limit_is_one_MiB(int extra, HttpStatusCode expected) + { + var seed = await SeedAsync(); + var body = BODY + new string(' ', 1_048_576 - Encoding.UTF8.GetByteCount(BODY) + extra); + using var response = await PostAsync(Url(seed), body, apiKey: seed.Key); + response.StatusCode.ShouldBe(expected); + if (extra != 0) await ShouldHaveNoRequestsAsync(seed); + } + + [Fact] + public async Task Unknown_retired_missing_and_wrong_keys_have_the_same_public_failure() + { + var seed = await SeedAsync(); + using var missing = await PostAsync(Url(seed), "bad"); + using var wrong = await PostAsync(Url(seed), "bad", apiKey: "wrong"); + using var unknown = await PostAsync($"/api/ext/{Guid.NewGuid()}/run", "bad", apiKey: seed.Key); + await new TenantHarness(factory).DbAsync(db => db.WorkflowExternalTrigger.Where(t => t.Id == seed.TriggerId) + .ExecuteUpdateAsync(s => s.SetProperty(t => t.DeletedDate, DateTimeOffset.UtcNow))); + using var retired = await PostAsync(Url(seed), "bad", apiKey: seed.Key); + foreach (var response in new[] { missing, wrong, unknown, retired }) + { + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + var payload = await response.Content.ReadFromJsonAsync(); + payload.GetProperty("code").GetString().ShouldBe("unauthorized"); + payload.GetProperty("message").GetString().ShouldBe("Invalid external trigger credentials."); + } + } + [Fact] public async Task A_contract_violation_is_400_with_the_ingress_errors_unchanged() { var seed = await SeedAsync(); const string INVALID_BODY = """{"service":7}"""; using var scope = factory.Services.CreateScope(); - var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.Slug, seed.Key, null, INVALID_BODY, false), CancellationToken.None); + var expected = await scope.ServiceProvider.GetRequiredService().ReceiveAsync(new(seed.TriggerId, seed.Key, null, INVALID_BODY, false), CancellationToken.None); using var response = await PostAsync(Url(seed), INVALID_BODY, apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var payload = await response.Content.ReadFromJsonAsync(); @@ -251,6 +278,22 @@ public async Task A_validated_endpoint_asks_for_retry_after_when_its_run_budget_ refused.Headers.RetryAfter!.Delta.ShouldBe(ExternalTriggerRunAdmission.Window); } + [Fact] + public async Task The_request_limiter_returns_retry_after_for_canonical_UUID_targets() + { + var id = Guid.NewGuid(); + for (var i = 0; i < WebhookIngressRateLimitExtension.PermitsPerWindow; i++) + { + using var response = await PostAsync($"/api/ext/{id:D}/run", "{}"); + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + } + using var refused = await PostAsync($"/api/ext/{id.ToString("N").ToUpperInvariant()}/run", "{}"); + refused.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); + refused.Headers.RetryAfter.ShouldNotBeNull().Delta.ShouldNotBeNull().ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); + var body = await refused.Content.ReadFromJsonAsync(); + body.GetProperty("code").GetString().ShouldBe("rate_limited"); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -302,7 +345,9 @@ public async Task An_authority_refusal_is_403_without_a_run() using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); var payload = await response.Content.ReadFromJsonAsync(); - payload.GetProperty("errors").EnumerateArray().ShouldNotBeEmpty(); + payload.GetProperty("code").GetString().ShouldBe("forbidden"); + payload.GetProperty("message").GetString().ShouldBe("External trigger execution is forbidden."); + (await response.Content.ReadAsStringAsync()).ShouldNotContain("membership-revoked"); (await RunsAsync(seed)).ShouldBeEmpty(); } @@ -334,7 +379,9 @@ public async Task A_completion_policy_refusal_is_422_without_a_run() using var response = await PostAsync(Url(seed), BODY, apiKey: seed.Key); response.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); var payload = await response.Content.ReadFromJsonAsync(); - payload.GetProperty("errors").EnumerateArray().ShouldNotBeEmpty(); + payload.GetProperty("code").GetString().ShouldBe("external_trigger_completion_refused"); + payload.GetProperty("message").GetString().ShouldBe("External trigger execution was refused."); + (await response.Content.ReadAsStringAsync()).ShouldNotContain("ModeProfileRegistry"); (await RunsAsync(seed)).ShouldBeEmpty(); } @@ -356,7 +403,7 @@ public async Task A_refusal_is_audited_once_per_trigger_reason_and_day() } } - private static string Url(Seed seed) => $"/api/ext/{seed.Slug}/run"; + private static string Url(Seed seed) => $"/api/ext/{seed.TriggerId}/run"; private async Task PostAsync(string url, string body, string? authorization = null, string? apiKey = null, string? idempotencyKey = null) { @@ -401,7 +448,7 @@ private async Task SeedCredentialAsync(Guid userId, Guid teamId, Guid work var db = scope.ServiceProvider.GetRequiredService(); var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == workflowId && t.TeamId == teamId && t.DeletedDate == null); var key = scope.ServiceProvider.GetRequiredService().Open(row.KeyCiphertext); - return new(userId, teamId, workflowId, slug, key); + return new(userId, teamId, workflowId, row.Id, key); } private Task RunsAsync(Seed seed) => new TenantHarness(factory).DbAsync(db => db.WorkflowRun.AsNoTracking().Where(r => r.WorkflowId == seed.WorkflowId).Select(r => r.Id).ToArrayAsync()); diff --git a/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs index cff3f3c77..25159c90b 100644 --- a/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs +++ b/backend/tests/CodeSpace.E2ETests/Webhooks/WebhookIngressSourceLimitE2ETests.cs @@ -50,6 +50,7 @@ public async Task One_source_changing_the_target_on_every_request_is_limited_by_ var over = await client.PostAsync(RotatingTarget(WebhookIngressRateLimitExtension.SourcePermitsPerWindow), new StringContent("{}")); over.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests, customMessage: "a sender that changes the target every time must still run out — check the GlobalLimiter in WebhookIngressRateLimitExtension; without it each new target is a new limiter and nothing ever answers 429"); + over.Headers.RetryAfter.ShouldNotBeNull().Delta.ShouldNotBeNull().ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero); } [Fact] diff --git a/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs b/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs index 2b584d781..91dcaf1df 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Middlewares/TransactionalBehaviorPostCommitTests.cs @@ -30,6 +30,33 @@ private sealed record ProbeCommand : ICommand; private sealed record SweepProbeCommand : ICommand, INonTransactionalCommand; + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Completion_callbacks_follow_the_outer_transaction_even_on_cancellation(bool cancel) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var callbacks = scope.Resolve(); + var behavior = new TransactionalBehavior(db, + scope.Resolve(), NullLogger>.Instance, callbacks); + var committed = false; + var released = false; + using var cancellation = new CancellationTokenSource(); + Task Next(CancellationToken ct) + { + callbacks.RegisterAfterCommit(_ => { committed = true; return Task.CompletedTask; }); + callbacks.RegisterAfterRollback(() => released = true); + if (cancel) { cancellation.Cancel(); ct.ThrowIfCancellationRequested(); } + return Task.FromResult(Unit.Value); + } + var error = await Record.ExceptionAsync(() => behavior.Handle(new ProbeCommand(), Next, cancellation.Token)); + committed.ShouldBe(!cancel); + released.ShouldBe(cancel); + if (cancel) error.ShouldBeAssignableTo(); + else error.ShouldBeNull(); + } + [Fact] public async Task A_non_transactional_command_reaches_its_handler_with_no_ambient_transaction() { diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs index 84ffdb12c..73df59612 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs @@ -1,43 +1,137 @@ -using System.Net; +using System.Text.Json; using System.Reflection; using CodeSpace.Api.Controllers; using CodeSpace.Api.Extensions; -using Microsoft.AspNetCore.Authorization; +using CodeSpace.Api.Http; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Queries.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using MediatR; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.RateLimiting; -using Microsoft.AspNetCore.Routing; +using Autofac; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Data.Common; using Shouldly; namespace CodeSpace.IntegrationTests.Workflows; [Trait("Category", "Integration")] -public sealed class ExternalTriggerIngressContractTests +[Collection(PostgresCollection.Name)] +public sealed class ExternalTriggerIngressContractTests(PostgresFixture fixture) { + [Fact] + public async Task Verification_projects_only_identity_and_digest_without_a_transaction() + { + var reads = new AuthenticationReads(); + using var scope = fixture.BeginScope(builder => + { + var options = new DbContextOptionsBuilder().UseNpgsql(fixture.ConnectionString) + .UseSnakeCaseNamingConvention().AddInterceptors(reads).Options; + builder.RegisterInstance(options).As>(); + }); + var result = await scope.Resolve().Send(new VerifyExternalTriggerQuery { TriggerId = Guid.NewGuid(), PresentedKey = "wrong" }); + result.Kind.ShouldBe(ExternalTriggerDeliveryKind.Unauthorized); + var sql = reads.Commands.ShouldHaveSingleItem(); + sql.ShouldContain("key_digest"); + sql.ShouldNotContain("key_ciphertext"); + sql.ShouldNotContain("definition"); + sql.ShouldNotContain("activation"); + scope.Resolve().Database.CurrentTransaction.ShouldBeNull(); + } + [Fact] public void The_ext_route_is_anonymous_bounded_and_rate_limited_like_webhook_ingress() { - var controller = typeof(WebhooksController).Assembly.GetType("CodeSpace.Api.Controllers.ExternalTriggersController").ShouldNotBeNull(); - controller.GetCustomAttribute().ShouldNotBeNull(); + var controller = typeof(ExternalTriggersController); controller.GetCustomAttribute().ShouldNotBeNull(); controller.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("api/ext"); var method = controller.GetMethod("Run").ShouldNotBeNull(); - method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{slug:length(1,64)}/run"); - ((IRequestSizeLimitMetadata)method.GetCustomAttribute().ShouldNotBeNull()).MaxRequestBodySize.ShouldBe(WebhooksController.MaxDeliveryBodyBytes); - method.GetParameters().ShouldNotContain(p => p.Name == "key" && p.GetCustomAttributes().Any(), - "a credential in the query string lands in access logs; the route admits headers only"); - var policy = method.GetCustomAttribute() ?? controller.GetCustomAttribute(); - policy.ShouldNotBeNull().PolicyName.ShouldBe(WebhookIngressRateLimitExtension.PolicyName); + method.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("{triggerId:guid}/run"); + ((IRequestSizeLimitMetadata)method.GetCustomAttribute().ShouldNotBeNull()).MaxRequestBodySize.ShouldBe(1_048_577); + controller.GetCustomAttribute().ShouldNotBeNull().PolicyName.ShouldBe(WebhookIngressRateLimitExtension.PolicyName); + method.GetParameters().ShouldNotContain(p => p.Name == "key" && p.GetCustomAttribute() != null); } [Fact] - public void The_target_partition_uses_the_slug_and_source_address() + public async Task Failed_authentication_does_not_read_the_request_stream() + { + using var scope = fixture.BeginScope(); + var context = new DefaultHttpContext(); + context.Request.Body = new UnreadableStream(); + var controller = new ExternalTriggersController(scope.Resolve()) { ControllerContext = new ControllerContext { HttpContext = context } }; + (await controller.Run(Guid.NewGuid(), CancellationToken.None)).ShouldBeOfType(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task The_reader_counts_actual_bytes_without_ContentLength(int extra) { var context = new DefaultHttpContext(); - context.Request.RouteValues = new RouteValueDictionary { ["slug"] = "alerts" }; - context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.7"); + var bytes = new byte[1_048_576 + extra]; + Array.Fill(bytes, (byte)' '); + bytes[0] = (byte)'{'; bytes[1] = (byte)'}'; + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentLength.ShouldBeNull(); + if (extra == 0) + { + using var result = await BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None); + result.RootElement.ValueKind.ShouldBe(JsonValueKind.Object); + } + else await Should.ThrowAsync(() => BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None)); + } - WebhookIngressRateLimitExtension.PartitionKey(context).ShouldBe("alerts|203.0.113.7"); + [Theory] + [InlineData(64, true)] + [InlineData(65, false)] + public async Task Parser_depth_has_a_controlled_boundary(int depth, bool accepted) + { + var json = string.Concat(Enumerable.Repeat("[", depth)) + "0" + string.Concat(Enumerable.Repeat("]", depth)); + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + if (accepted) + { + using var body = await BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None); + ExternalTriggerPayloadValidator.ValidatePayload(body.RootElement).ShouldBeEmpty(); + } + else await Should.ThrowAsync(() => BoundedJsonBodyReader.ReadAsync(context.Request, CancellationToken.None)); + } + + [Fact] + public void Equivalent_UUID_spellings_share_the_target_request_budget() + { + var a = new DefaultHttpContext(); + var b = new DefaultHttpContext(); + a.Request.RouteValues["triggerId"] = "56DA5193-D2A0-4309-9ED7-EEF395D72124"; + b.Request.RouteValues["triggerId"] = "56da5193d2a043099ed7eef395d72124"; + WebhookIngressRateLimitExtension.PartitionKey(a).ShouldBe(WebhookIngressRateLimitExtension.PartitionKey(b)); + a.Request.RouteValues["triggerId"] = Guid.NewGuid(); + WebhookIngressRateLimitExtension.PartitionKey(a).ShouldNotBe(WebhookIngressRateLimitExtension.PartitionKey(b)); + } + + private sealed class UnreadableStream : MemoryStream + { + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => throw new InvalidOperationException("Unauthenticated body was read."); + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => throw new InvalidOperationException("Unauthenticated body was read."); + } + + private sealed class AuthenticationReads : DbCommandInterceptor + { + public List Commands { get; } = []; + public override ValueTask> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, + InterceptionResult result, CancellationToken ct = default) + { + command.Transaction.ShouldBeNull("cheap authentication must never start a transaction"); + Commands.Add(command.CommandText); + return ValueTask.FromResult(result); + } } } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs index 79a0999d1..ebafc4b27 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Autofac; +using CodeSpace.Core.Failures; using CodeSpace.Core.Middlewares.Transactional; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; @@ -13,6 +14,7 @@ using CodeSpace.Messages.Constants; using CodeSpace.Messages.Dtos.Workflows; using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Failures; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Shouldly; @@ -24,7 +26,8 @@ namespace CodeSpace.IntegrationTests.Workflows; public class ExternalTriggerIngressFlowTests(PostgresFixture fixture) { private const string Config = """{"slug":"alerts","inputSchema":{"type":"object","properties":{"service":{"type":"string"}},"required":["service"]}}"""; - private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid ActivationId, string Slug, string Key); + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid ActivationId, Guid TriggerId, string Slug, string Key); + private sealed record RollbackProbe : CodeSpace.Messages.Mediation.ICommand; [Fact] public async Task A_correct_key_starts_a_run_that_carries_the_activation_lineage() @@ -81,10 +84,45 @@ public async Task A_rejected_idempotency_header_precedes_body_validation() } [Fact] - public async Task An_unknown_slug_is_not_found() + public async Task An_unknown_trigger_is_unauthorized() { var seed = await SeedAsync(); - (await ReceiveAsync(seed with { Slug = "nope" })).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await ReceiveAsync(seed with { TriggerId = Guid.NewGuid() })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + } + + [Fact] + public async Task Cheap_verification_does_not_require_a_definition_or_activation() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + await db.Workflow.Where(w => w.Id == seed.WorkflowId).ExecuteUpdateAsync(s => s.SetProperty(w => w.LatestVersion, 999)); + await db.WorkflowActivation.Where(a => a.WorkflowId == seed.WorkflowId).ExecuteDeleteAsync(); + (await scope.Resolve().VerifyAsync(seed.TriggerId, seed.Key, CancellationToken.None)) + .Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + db.Database.CurrentTransaction.ShouldBeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_ticket_cannot_bypass_a_rotation_or_retirement_during_body_read(bool retire) + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var ingress = scope.Resolve(); + (await ingress.VerifyAsync(seed.TriggerId, seed.Key, CancellationToken.None)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var row = await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId); + if (retire) row.DeletedDate = DateTimeOffset.UtcNow; + else row.KeyDigest = ExternalTriggerKeySealer.Digest("rotated"); + await db.SaveChangesAsync(); + } + (await ingress.ReceiveAsync(new(seed.TriggerId, seed.Key, null, "{}", false), CancellationToken.None)) + .Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await RunsAsync(seed)).ShouldBeEmpty(); } [Theory] @@ -96,19 +134,19 @@ public async Task A_trigger_for_a_replaced_node_is_not_found_before_authenticati using (var edit = fixture.BeginScope()) { var db = edit.Resolve(); - var trigger = await db.WorkflowExternalTrigger.SingleAsync(t => t.Slug == seed.Slug); + var trigger = await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId); trigger.NodeId = "removed-node"; await db.SaveChangesAsync(); } var outcome = await ReceiveAsync(seed, presentedKey: correctKey ? seed.Key : "wrong"); - outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + outcome.Kind.ShouldBe(correctKey ? ExternalTriggerOutcomeKind.NotFound : ExternalTriggerOutcomeKind.Unauthorized); (await RunsAsync(seed)).ShouldBeEmpty(); (await RequestsAsync(seed)).ShouldBeEmpty(); } [Fact] - public async Task A_deduplicated_delivery_without_a_matching_run_is_accepted_and_warned_without_dispatch() + public async Task A_deduplicated_delivery_without_a_matching_run_is_a_server_error_without_dispatch() { var seed = await SeedAsync(); var logger = new IngressWarnings(); @@ -122,16 +160,15 @@ public async Task A_deduplicated_delivery_without_a_matching_run_is_accepted_and var jobs = scope.Resolve(); var before = jobs.Calls.Count; - var outcome = await scope.Resolve().ReceiveAsync( - new(seed.Slug, seed.Key, "missing-run", """{"service":"api"}""", false), CancellationToken.None); - - outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); - outcome.RunId.ShouldBe(Guid.Empty); + var error = await Record.ExceptionAsync(() => scope.Resolve().ReceiveAsync( + new(seed.TriggerId, seed.Key, "missing-run", """{"service":"api"}""", false), CancellationToken.None)); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Internal); + FailureClassifier.Classify(error).ClientMessage.ShouldBe(FailureClassifier.MaskedMessage); dispatcher.Calls.ShouldBe(0); jobs.Calls.Count.ShouldBe(before); (await RunsAsync(seed)).ShouldBeEmpty(); (await RequestsAsync(seed)).ShouldBeEmpty(); - logger.Warnings.ShouldContain(w => w.Contains(seed.Slug) && w.Contains("run could not be found")); } private sealed class EmptyRunStarter : IRunStarter @@ -181,12 +218,13 @@ public async Task An_unavailable_or_ambiguous_endpoint_is_not_found(string state case "deleted-activation": activation.DeletedDate = DateTimeOffset.UtcNow; break; case "disabled-workflow": workflow.Enabled = false; break; case "deleted-workflow": workflow.DeletedDate = DateTimeOffset.UtcNow; break; - case "deleted-trigger": (await db.WorkflowExternalTrigger.SingleAsync(t => t.Slug == seed.Slug)).DeletedDate = DateTimeOffset.UtcNow; break; + case "deleted-trigger": (await db.WorkflowExternalTrigger.SingleAsync(t => t.Id == seed.TriggerId)).DeletedDate = DateTimeOffset.UtcNow; break; case "multiple-activations": db.WorkflowActivation.Add(Activation(seed, Guid.NewGuid(), Config)); break; } await db.SaveChangesAsync(); } - (await ReceiveAsync(seed, presentedKey: "wrong", body: "bad")).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await ReceiveAsync(seed, presentedKey: "wrong", body: "bad")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); + (await ReceiveAsync(seed)).Kind.ShouldBe(state == "deleted-trigger" ? ExternalTriggerOutcomeKind.Unauthorized : ExternalTriggerOutcomeKind.NotFound); (await RequestsAsync(seed)).ShouldBeEmpty(); } @@ -208,7 +246,7 @@ public async Task An_authenticated_delivery_cannot_exceed_the_per_trigger_run_bu var admitted = 0; for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow + 1; i++) { - var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall(seed.Slug, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall(seed.TriggerId, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); if (outcome.Kind == ExternalTriggerOutcomeKind.Accepted) admitted++; } @@ -228,6 +266,54 @@ public async Task The_same_idempotency_key_twice_starts_one_run_and_returns_the_ (await RunsAsync(seed)).Length.ShouldBe(1); } + [Fact] + public async Task Reusing_an_idempotency_key_with_different_content_is_a_conflict() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "conflict"); + var second = await ReceiveAsync(seed, idempotencyKey: "conflict", body: "{\"service\":\"different\"}"); + second.Kind.ShouldBe(ExternalTriggerOutcomeKind.Unprocessable); + (await RunsAsync(seed)).ShouldHaveSingleItem().Id.ShouldBe(first.RunId); + } + + [Fact] + public async Task Replays_do_not_consume_the_next_new_event_capacity() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "replay"); + for (var i = 0; i < ExternalTriggerRunAdmission.PermitsPerWindow; i++) + { + var replay = await ReceiveAsync(seed, idempotencyKey: "replay"); + replay.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + replay.RunId.ShouldBe(first.RunId); + } + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + } + + [Fact] + public async Task An_equivalent_replay_precedes_the_current_schema() + { + var seed = await SeedAsync(); + var first = await ReceiveAsync(seed, idempotencyKey: "same", body: "{\"service\":\"api\",\"count\":1}"); + using (var edit = fixture.BeginScope()) + { + var db = edit.Resolve(); + var workflow = await db.Workflow.SingleAsync(w => w.Id == seed.WorkflowId); + var definition = JsonSerializer.Deserialize(workflow.DefinitionJson, WorkflowJson.Options)!; + definition = definition with { Nodes = definition.Nodes.Select(n => n.Id == "start" ? n with + { + Config = WorkflowsTestSeed.Json("{\"inputSchema\":{\"type\":\"object\",\"required\":[\"newField\"]}}"), + } : n).ToArray() }; + workflow.LatestVersion = 2; + workflow.DefinitionJson = JsonSerializer.Serialize(definition, WorkflowJson.Options); + db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 2, DefinitionJson = workflow.DefinitionJson, DefinitionHash = "test", CommittedAt = DateTimeOffset.UtcNow, CreatedBy = seed.UserId }); + await db.SaveChangesAsync(); + } + var replay = await ReceiveAsync(seed, idempotencyKey: "same", body: "{\"count\":1.0, \"service\":\"a\\u0070i\"}"); + replay.Kind.ShouldBe(ExternalTriggerOutcomeKind.Deduplicated); + replay.RunId.ShouldBe(first.RunId); + } + [Fact] public async Task Concurrent_deliveries_of_one_key_start_exactly_one_run() { @@ -239,13 +325,57 @@ public async Task Concurrent_deliveries_of_one_key_start_exactly_one_run() results.Count(r => r.Kind == ExternalTriggerOutcomeKind.Deduplicated).ShouldBe(7); } + [Fact] + public async Task Concurrent_different_payloads_with_one_key_have_one_winner() + { + var seed = await SeedAsync(); + var outcomes = await Task.WhenAll(Enumerable.Range(0, 8).Select(i => ReceiveAsync(seed, + idempotencyKey: "different", body: $"{{\"service\":\"{i}\"}}"))); + outcomes.Count(o => o.Kind == ExternalTriggerOutcomeKind.Accepted).ShouldBe(1); + outcomes.Count(o => o.Kind == ExternalTriggerOutcomeKind.Unprocessable).ShouldBe(7); + (await RunsAsync(seed)).ShouldHaveSingleItem(); + } + + [Fact] + public async Task Tiny_json_numbers_round_trip_without_becoming_zero() + { + var seed = await SeedAsync(); + (await ReceiveAsync(seed, body: "{\"service\":\"api\",\"tiny\":1e-330}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using var persisted = JsonDocument.Parse((await RunsAsync(seed)).ShouldHaveSingleItem().RunRequest.NormalizedPayloadJson); + using var expected = JsonDocument.Parse("1e-330"); + JsonElement.DeepEquals(persisted.RootElement.GetProperty("tiny"), expected.RootElement).ShouldBeTrue(); + } + + [Fact] + public async Task An_outer_rollback_releases_capacity_and_does_not_dispatch() + { + var seed = await SeedAsync(); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var behavior = new TransactionalBehavior( + db, scope.Resolve(), Microsoft.Extensions.Logging.Abstractions.NullLogger>.Instance, + scope.Resolve()); + var request = new RollbackProbe(); + var error = await Record.ExceptionAsync(() => behavior.Handle(request, async ct => + { + (await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, "{\"service\":\"api\"}", false), ct)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + throw new OperationCanceledException(); + }, CancellationToken.None)); + error.ShouldBeOfType(); + (await RunsAsync(seed)).ShouldBeEmpty(); + var admission = scope.Resolve(); + var reservations = Enumerable.Range(0, 60).Select(_ => admission.Reserve(seed.TriggerId)).ToArray(); + try { reservations.ShouldAllBe(r => r != null); } + finally { foreach (var reservation in reservations) reservation?.Release(); } + } + [Fact] public async Task A_recreated_activation_is_resolved_fresh_and_the_trigger_still_deduplicates() { var seed = await SeedAsync(); using var scope = fixture.BeginScope(); var ingress = scope.Resolve(); - var call = new ExternalTriggerCall(seed.Slug, seed.Key, "evt-1", """{"service":"api"}""", false); + var call = new ExternalTriggerCall(seed.TriggerId, seed.Key, "evt-1", """{"service":"api"}""", false); (await ingress.ReceiveAsync(call, CancellationToken.None)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); var replacement = Guid.NewGuid(); using (var edit = fixture.BeginScope()) @@ -317,7 +447,7 @@ public async Task Dispatch_waits_until_the_ambient_transaction_commits() Guid runId; await using (var transaction = await db.Database.BeginTransactionAsync()) { - var outcome = await scope.Resolve().ReceiveAsync(new(seed.Slug, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); + var outcome = await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, """{"service":"api"}""", false), CancellationToken.None); runId = outcome.RunId; (await db.WorkflowRun.AsNoTracking().SingleAsync(r => r.Id == runId)).Status.ShouldBe(WorkflowRunStatus.Pending); (await RunsAsync(seed)).ShouldBeEmpty(); @@ -333,7 +463,7 @@ private async Task SeedAsync(string config = Config) using var scope = fixture.BeginScopeAs(userId, teamId); var db = scope.Resolve(); var key = scope.Resolve().Mint(); - var seed = new Seed(teamId, userId, Guid.NewGuid(), Guid.NewGuid(), "alerts-" + Guid.NewGuid().ToString("N"), key.Key); + var seed = new Seed(teamId, userId, Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "alerts-" + Guid.NewGuid().ToString("N"), key.Key); var graph = WorkflowsTestSeed.MinimalDefinition() with { Nodes = new[] @@ -346,7 +476,7 @@ private async Task SeedAsync(string config = Config) db.Workflow.Add(new Workflow { Id = seed.WorkflowId, TeamId = teamId, Slug = seed.Slug, Name = "External ingress", DefinitionJson = definition, Enabled = true, CreatedBy = userId, LastModifiedBy = userId }); db.WorkflowVersion.Add(new WorkflowVersion { WorkflowId = seed.WorkflowId, Version = 1, DefinitionJson = definition, DefinitionHash = "test", CommittedAt = DateTimeOffset.UtcNow, CreatedDate = DateTimeOffset.UtcNow, CreatedBy = userId }); db.WorkflowActivation.Add(Activation(seed, seed.ActivationId, config)); - db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger { Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = seed.WorkflowId, NodeId = "start", Slug = seed.Slug, KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId }); + db.WorkflowExternalTrigger.Add(new WorkflowExternalTrigger { Id = seed.TriggerId, TeamId = teamId, WorkflowId = seed.WorkflowId, NodeId = "start", KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = userId, LastModifiedBy = userId }); await db.SaveChangesAsync(); return seed; } @@ -360,7 +490,7 @@ private async Task SeedAsync(string config = Config) private async Task ReceiveAsync(Seed seed, string? presentedKey = "use-seeded-key", string? idempotencyKey = null, string body = "{\"service\": \"api\"}", bool rejected = false) { using var scope = fixture.BeginScope(); - return await scope.Resolve().ReceiveAsync(new(seed.Slug, presentedKey == "use-seeded-key" ? seed.Key : presentedKey, idempotencyKey, body, rejected), CancellationToken.None); + return await scope.Resolve().ReceiveAsync(new(seed.TriggerId, presentedKey == "use-seeded-key" ? seed.Key : presentedKey, idempotencyKey, body, rejected), CancellationToken.None); } private async Task RunsAsync(Seed seed) diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs index 1490fa61e..b25d14ca7 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs @@ -23,15 +23,12 @@ namespace CodeSpace.IntegrationTests.Workflows; [Trait("Category", "Integration")] public sealed class ExternalTriggerManagementFlowTests(PostgresFixture fixture) { - private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, string Slug, string Key); + private sealed record Seed(Guid TeamId, Guid UserId, Guid WorkflowId, Guid TriggerId, string Slug, string Key); [Fact] - public async Task Metadata_returns_the_live_rename_after_a_stale_draft_save() + public async Task Metadata_returns_the_stable_trigger_identity_without_a_slug() { var seed = await SeedAsync(); - var renamed = "live-" + Guid.NewGuid().ToString("N"); - using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) - await scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = renamed }); await UpdateAsync(seed, Definition(seed.Slug)); using var read = fixture.BeginScopeAs(seed.UserId, seed.TeamId); var result = await read.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); @@ -39,7 +36,38 @@ public async Task Metadata_returns_the_live_rename_after_a_stale_draft_save() trigger.Id.ShouldBe((await RowAsync(seed)).Id); trigger.WorkflowId.ShouldBe(seed.WorkflowId); trigger.NodeId.ShouldBe("start"); - trigger.Slug.ShouldBe(renamed); + JsonSerializer.SerializeToElement(trigger, WorkflowJson.Options).TryGetProperty("slug", out _).ShouldBeFalse(); + } + + [Fact] + public async Task Saving_a_legacy_slug_removes_it_without_changing_the_trigger_credential() + { + var seed = await SeedAsync(); + var before = await RowAsync(seed); + string originalDefinition; + string originalHash; + using (var history = fixture.BeginScope()) + { + var original = await history.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 1); + originalDefinition = original.DefinitionJson; + originalHash = original.DefinitionHash; + } + await UpdateAsync(seed, Definition("legacy-name")); + var after = await RowAsync(seed); + after.Id.ShouldBe(before.Id); + after.KeyDigest.ShouldBe(before.KeyDigest); + after.KeyCiphertext.ShouldBe(before.KeyCiphertext); + using var scope = fixture.BeginScope(); + var version = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 2); + var definition = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + definition.Nodes.Single(n => n.Id == "start").Config.TryGetProperty("slug", out _).ShouldBeFalse(); + version.DefinitionHash.ShouldBe(DefinitionHash.Compute(definition)); + var preserved = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(v => v.WorkflowId == seed.WorkflowId && v.Version == 1); + preserved.DefinitionJson.ShouldBe(originalDefinition); + preserved.DefinitionHash.ShouldBe(originalHash); } [Fact] @@ -70,7 +98,7 @@ public async Task Metadata_lists_every_live_row_without_assuming_a_single_trigge var seed = await SeedAsync(); using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); await scope.Resolve().EnsureForNodeAsync(seed.WorkflowId, seed.TeamId, - "second", "second-" + Guid.NewGuid().ToString("N"), seed.UserId, CancellationToken.None); + "second", seed.UserId, CancellationToken.None); var result = await scope.Resolve().Send(new ListWorkflowExternalTriggersQuery { WorkflowId = seed.WorkflowId }); result!.Triggers.Select(t => t.NodeId).Order().ShouldBe(new[] { "second", "start" }); result.Triggers.ShouldAllBe(t => t.WorkflowId == seed.WorkflowId); @@ -158,7 +186,6 @@ public async Task Saving_the_workflow_again_leaves_the_key_and_endpoint_working( after.Id.ShouldBe(before.Id); after.KeyDigest.ShouldBe(before.KeyDigest); after.KeyCiphertext.ShouldBe(before.KeyCiphertext); - after.Slug.ShouldBe(seed.Slug); (await ReceiveAsync(seed, body: edit == "schema" ? "{\"service\":7}" : "{\"service\":\"api\"}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); if (edit == "schema") (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); @@ -172,17 +199,16 @@ public async Task Saving_the_workflow_again_leaves_the_key_and_endpoint_working( } [Fact] - public async Task An_unusable_draft_is_refused_without_damaging_the_live_endpoint() + public async Task A_legacy_name_does_not_affect_the_live_endpoint() { var seed = await SeedAsync(); (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); var before = await RowAsync(seed); - var error = await Should.ThrowAsync(() => UpdateAsync(seed, Definition("告警"))); + await UpdateAsync(seed, Definition("告警")); var after = await RowAsync(seed); after.Id.ShouldBe(before.Id); - after.Slug.ShouldBe(before.Slug); after.KeyDigest.ShouldBe(before.KeyDigest); after.KeyCiphertext.ShouldBe(before.KeyCiphertext); var outcome = await ReceiveAsync(seed); @@ -192,9 +218,6 @@ public async Task An_unusable_draft_is_refused_without_damaging_the_live_endpoin var run = await scope.Resolve().WorkflowRun.SingleAsync(r => r.Id == outcome.RunId); run.WorkflowId.ShouldBe(seed.WorkflowId); - error.ParamName.ShouldBe("slug"); - error.Message.ShouldContain("slug"); - error.Message.ShouldContain("Hyphens and underscores are allowed"); } [Fact] @@ -202,16 +225,14 @@ public async Task Reveal_hands_back_the_minted_key() { var seed = await SeedAsync(); using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); - (await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug })).ShouldBe(seed.Key); + (await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = seed.TriggerId })).ShouldBe(seed.Key); } [Theory] [InlineData(TeamRole.Viewer, "reveal")] [InlineData(TeamRole.Viewer, "rotate")] - [InlineData(TeamRole.Viewer, "rename")] [InlineData(TeamRole.Member, "reveal")] [InlineData(TeamRole.Member, "rotate")] - [InlineData(TeamRole.Member, "rename")] [InlineData(TeamRole.Admin, "reveal")] public async Task Management_requires_workflows_write(TeamRole role, string operation) { @@ -223,7 +244,7 @@ public async Task Management_requires_workflows_write(TeamRole role, string oper await db.SaveChangesAsync(); } using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); - var error = await Record.ExceptionAsync(() => ManageAsync(scope.Resolve(), operation, seed.Slug, "renamed-" + Guid.NewGuid().ToString("N"))); + var error = await Record.ExceptionAsync(() => ManageAsync(scope.Resolve(), operation, seed.TriggerId)); if (role == TeamRole.Viewer) error.ShouldBeOfType(); else error.ShouldBeNull(); } @@ -234,58 +255,78 @@ public async Task Rotating_invalidates_the_old_key_immediately_and_reveal_return var seed = await SeedAsync(); string fresh; using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) - fresh = await scope.Resolve().Send(new RotateExternalTriggerKeyCommand { Slug = seed.Slug }); + fresh = await scope.Resolve().Send(new RotateExternalTriggerKeyCommand { TriggerId = seed.TriggerId }); fresh.ShouldNotBe(seed.Key); (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); (await ReceiveAsync(seed with { Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); await UpdateAsync(seed, Definition(seed.Slug)); using var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId); - (await verify.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug })).ShouldBe(fresh); + (await verify.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = seed.TriggerId })).ShouldBe(fresh); } [Fact] - public async Task Renaming_moves_the_endpoint_and_a_stale_draft_save_does_not_move_it_back() + public async Task Rotation_waits_for_workflow_retirement_and_cannot_restore_the_retired_key() { var seed = await SeedAsync(); - var renamed = "prod-alerts-v2-" + Guid.NewGuid().ToString("N"); - using (var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) - await scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = renamed }); - await UpdateAsync(seed, Definition(seed.Slug)); - (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); - (await ReceiveAsync(seed with { Slug = renamed })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); - (await RowAsync(seed)).KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); + using var retirement = fixture.BeginScope(); + var retirementDb = retirement.Resolve(); + await using var retirementTransaction = await retirementDb.Database.BeginTransactionAsync(); + await retirementDb.Database.ExecuteSqlInterpolatedAsync($"SELECT id FROM workflow WHERE id = {seed.WorkflowId} FOR UPDATE"); + await retirementDb.WorkflowExternalTrigger.Where(trigger => trigger.Id == seed.TriggerId) + .ExecuteUpdateAsync(update => update + .SetProperty(trigger => trigger.DeletedDate, DateTimeOffset.UtcNow) + .SetProperty(trigger => trigger.KeyCiphertext, string.Empty)); + + using var rotation = fixture.BeginScopeAs(seed.UserId, seed.TeamId); + var rotationDb = rotation.Resolve(); + await rotationDb.Database.OpenConnectionAsync(); + var processId = await rotationDb.Database.SqlQueryRaw("SELECT pg_backend_pid() AS \"Value\"").SingleAsync(); + var rotationTask = rotation.Resolve().RotateKeyAsync( + seed.TriggerId, seed.TeamId, seed.UserId, CancellationToken.None); + + await WaitForLockAsync(processId); + await retirementTransaction.CommitAsync(); + + var error = await Record.ExceptionAsync(async () => await rotationTask); + error.ShouldBeOfType(); + using var verify = fixture.BeginScope(); + var retired = await verify.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(trigger => trigger.Id == seed.TriggerId); + retired.DeletedDate.ShouldNotBeNull(); + retired.KeyCiphertext.ShouldBeEmpty(); } - [Fact] - public async Task Renaming_onto_another_teams_live_slug_is_refused_and_original_is_untouched() + private async Task WaitForLockAsync(int processId) { - var first = await SeedAsync(); - var other = await SeedAsync(); - using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); - var error = await Record.ExceptionAsync(() => scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = other.Slug, DesiredSlug = first.Slug })); - error.ShouldNotBeNull(); - FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Conflict); - (await ReceiveAsync(other)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); - (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (DateTimeOffset.UtcNow < deadline) + { + using var observer = fixture.BeginScope(); + var waitType = await observer.Resolve().Database + .SqlQueryRaw("SELECT wait_event_type AS \"Value\" FROM pg_stat_activity WHERE pid = {0}", processId) + .SingleOrDefaultAsync(); + if (waitType == "Lock") return; + await Task.Delay(10); + } + + throw new TimeoutException("The key rotation did not wait for the workflow lock."); } [Theory] [InlineData("reveal")] [InlineData("rotate")] - [InlineData("rename")] - public async Task Another_teams_slug_is_not_found(string operation) + public async Task Another_teams_trigger_is_not_found(string operation) { var first = await SeedAsync(); var other = await SeedAsync(); using var scope = fixture.BeginScopeAs(other.UserId, other.TeamId); - await Should.ThrowAsync(() => ManageAsync(scope.Resolve(), operation, first.Slug, "unused")); + await Should.ThrowAsync(() => ManageAsync(scope.Resolve(), operation, first.TriggerId)); (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); } [Theory] [InlineData("reveal")] [InlineData("rotate")] - [InlineData("rename")] public async Task Deleting_the_workflow_retires_its_trigger_so_the_key_stops_resolving(string operation) { var seed = await SeedAsync(); @@ -294,35 +335,33 @@ public async Task Deleting_the_workflow_retires_its_trigger_so_the_key_stops_res using (var verify = fixture.BeginScopeAs(seed.UserId, seed.TeamId)) { - var error = await Record.ExceptionAsync(() => ManageAsync(verify.Resolve(), operation, seed.Slug, "replacement-" + Guid.NewGuid().ToString("N"))); + var error = await Record.ExceptionAsync(() => ManageAsync(verify.Resolve(), operation, seed.TriggerId)); error.ShouldBeOfType(); FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.NotFound); } - (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.NotFound); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); using var audit = fixture.BeginScope(); var db = audit.Resolve(); var row = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == seed.WorkflowId); row.DeletedDate.ShouldNotBeNull(); row.KeyDigest.ShouldBe(ExternalTriggerKeySealer.Digest(seed.Key)); row.KeyCiphertext.ShouldBeEmpty(); - (await db.WorkflowExternalTrigger.CountAsync(t => t.Slug == seed.Slug && t.DeletedDate == null)).ShouldBe(0); + (await db.WorkflowExternalTrigger.CountAsync(t => t.Id == seed.TriggerId && t.DeletedDate == null)).ShouldBe(0); } [Theory] [InlineData("!!!")] [InlineData("告警")] [InlineData("")] - public async Task An_underivable_slug_is_refused_without_creating_a_workflow(string desired) + public async Task A_legacy_name_is_not_a_workflow_creation_constraint(string desired) { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); using var scope = fixture.BeginScopeAs(userId, teamId); - var error = await Record.ExceptionAsync(() => CreateAsync(scope, Definition(desired))); - error.ShouldNotBeNull(); - FailureClassifier.Classify(error).Kind.ShouldBeOneOf(FailureKind.Invalid, FailureKind.Unprocessable); + await CreateAsync(scope, Definition(desired)); using var verify = fixture.BeginScope(); - (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); - (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(1); + (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(1); } [Fact] @@ -348,26 +387,38 @@ public async Task An_unsupported_schema_keyword_is_refused_at_save_without_creat (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); } - [Fact] - public async Task An_underivable_rename_is_refused_and_keeps_the_old_endpoint() + [Theory] + [InlineData("{\"type\":\"object\",\"properties\":{\"\\uD800\":{\"type\":\"string\"}}}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\",\"title\":\"\\uD800\"}}}")] + [InlineData("{\"type\":\"object\",\"required\":[\"\\uD800\"]}")] + public async Task An_unstorable_schema_is_refused_before_input_projection(string schema) { - var seed = await SeedAsync(); - using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); - await Should.ThrowAsync(() => scope.Resolve().Send(new RenameExternalTriggerCommand { Slug = seed.Slug, DesiredSlug = "!!!" })); - (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var config = WorkflowsTestSeed.Json("{\"inputSchema\":" + schema + "}"); + var definition = Definition("legacy") with + { + Nodes = Definition("legacy").Nodes.Select(n => n.Id == "start" ? n with { Config = config } : n).ToArray(), + }; + var error = await Record.ExceptionAsync(() => CreateAsync(scope, definition)); + error.ShouldNotBeNull(); + FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Unprocessable); + using var verify = fixture.BeginScope(); + (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); } [Fact] - public async Task Creating_with_an_existing_slug_is_a_conflict_not_a_suffixed_endpoint() + public async Task Two_teams_can_save_the_same_legacy_name_with_independent_trigger_identities() { var seed = await SeedAsync(); var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); using var scope = fixture.BeginScopeAs(userId, teamId); - var error = await Record.ExceptionAsync(() => CreateAsync(scope, Definition(seed.Slug))); - error.ShouldNotBeNull(); - FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.Conflict); + var workflowId = await CreateAsync(scope, Definition(seed.Slug)); using var verify = fixture.BeginScope(); - (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(0); + var row = await verify.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(t => t.WorkflowId == workflowId); + row.Id.ShouldNotBe((await RowAsync(seed)).Id); + row.KeyDigest.ShouldNotBe(ExternalTriggerKeySealer.Digest(seed.Key)); } [Theory] @@ -385,7 +436,7 @@ public async Task Removing_or_replacing_the_node_retires_its_old_credential(bool Edges = [new EdgeDefinition { From = replace ? "replacement" : "start", To = "end" }], }; await UpdateAsync(seed, definition); - (await ReceiveAsync(seed)).Kind.ShouldBe(replace ? ExternalTriggerOutcomeKind.Unauthorized : ExternalTriggerOutcomeKind.NotFound); + (await ReceiveAsync(seed)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Unauthorized); using var scope = fixture.BeginScopeAs(seed.UserId, seed.TeamId); var rows = await scope.Resolve().WorkflowExternalTrigger.Where(t => t.WorkflowId == seed.WorkflowId).ToArrayAsync(); var retired = rows.Single(t => t.NodeId == "start"); @@ -394,9 +445,11 @@ public async Task Removing_or_replacing_the_node_retires_its_old_credential(bool retired.KeyCiphertext.ShouldBeEmpty(); if (replace) { - var fresh = await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { Slug = seed.Slug }); + var replacement = rows.Single(t => t.NodeId == "replacement"); + replacement.Id.ShouldNotBe(seed.TriggerId); + var fresh = await scope.Resolve().Send(new RevealExternalTriggerKeyCommand { TriggerId = replacement.Id }); fresh.ShouldNotBe(seed.Key); - (await ReceiveAsync(seed with { Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await ReceiveAsync(seed with { TriggerId = replacement.Id, Key = fresh })).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); } } @@ -429,8 +482,7 @@ private async Task SeedAsync() var workflowId = await CreateAsync(scope, Definition(slug)); var row = await scope.Resolve().WorkflowExternalTrigger.SingleAsync(t => t.WorkflowId == workflowId && t.DeletedDate == null); row.NodeId.ShouldBe("start"); - row.Slug.ShouldBe(slug); - return new(teamId, userId, workflowId, slug, scope.Resolve().Open(row.KeyCiphertext)); + return new(teamId, userId, workflowId, row.Id, slug, scope.Resolve().Open(row.KeyCiphertext)); } private async Task UpdateAsync(Seed seed, WorkflowDefinition definition) @@ -448,13 +500,12 @@ private async Task RowAsync(Seed seed) private async Task ReceiveAsync(Seed seed, string body = "{\"service\":\"api\"}") { using var scope = fixture.BeginScope(); - return await scope.Resolve().ReceiveAsync(new(seed.Slug, seed.Key, null, body, false), CancellationToken.None); + return await scope.Resolve().ReceiveAsync(new(seed.TriggerId, seed.Key, null, body, false), CancellationToken.None); } - private static Task ManageAsync(IMediator mediator, string operation, string slug, string desired) => operation switch + private static Task ManageAsync(IMediator mediator, string operation, Guid triggerId) => operation switch { - "reveal" => mediator.Send(new RevealExternalTriggerKeyCommand { Slug = slug }), - "rotate" => mediator.Send(new RotateExternalTriggerKeyCommand { Slug = slug }), - _ => mediator.Send(new RenameExternalTriggerCommand { Slug = slug, DesiredSlug = desired }), + "reveal" => mediator.Send(new RevealExternalTriggerKeyCommand { TriggerId = triggerId }), + _ => mediator.Send(new RotateExternalTriggerKeyCommand { TriggerId = triggerId }), }; } diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs new file mode 100644 index 000000000..b9e2f9491 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Workflows; +using CodeSpace.Core.Services.Workflows.Engine; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Workflows.Infrastructure; +using CodeSpace.Messages.Commands.Workflows; +using CodeSpace.Messages.Dtos.Workflows; +using CodeSpace.Messages.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Workflows; + +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class ExternalTriggerSchemaContractFlowTests(PostgresFixture fixture) +{ + [Fact] + public async Task Boolean_and_untyped_property_schemas_are_saved_and_resolve_through_workflow_inputs() + { + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var schema = WorkflowsTestSeed.Json("""{"type":"object","properties":{"allowed":true,"blocked":false,"untyped":{}}}"""); + var config = JsonSerializer.SerializeToElement(new { inputSchema = schema }); + var definition = WorkflowsTestSeed.MinimalDefinition() with + { + Outputs = [new WorkflowVariable { Name = "allowed", Schema = WorkflowsTestSeed.Json("true") }, + new WorkflowVariable { Name = "untyped", Schema = WorkflowsTestSeed.Json("{}") }], + Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = config, Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), + Inputs = WorkflowsTestSeed.Json("""{"allowed":"{{input.allowed}}","untyped":"{{input.untyped}}"} """) }], + }; + + var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "Boolean schema contract", Definition = definition, + Activations = [new WorkflowActivationInput { TypeKey = "trigger.external", Config = config }], + }); + var version = await scope.Resolve().WorkflowVersion.AsNoTracking() + .SingleAsync(candidate => candidate.WorkflowId == workflowId && candidate.Version == 1); + var saved = JsonSerializer.Deserialize(version.DefinitionJson, WorkflowJson.Options)!; + + saved.Inputs.Select(input => input.Name).ShouldBe(["allowed", "blocked", "untyped"]); + saved.Inputs[0].Schema.ValueKind.ShouldBe(JsonValueKind.True); + saved.Inputs[1].Schema.ValueKind.ShouldBe(JsonValueKind.False); + saved.Inputs[2].Schema.ValueKind.ShouldBe(JsonValueKind.Object); + + var trigger = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking() + .SingleAsync(candidate => candidate.WorkflowId == workflowId); + var key = scope.Resolve().Open(trigger.KeyCiphertext); + ExternalTriggerOutcome outcome; + using (var receive = fixture.BeginScope()) + outcome = await receive.Resolve().ReceiveAsync( + new(trigger.Id, key, null, """{"allowed":true,"untyped":{"value":"kept"}}""", false), CancellationToken.None); + outcome.Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + using (var engine = fixture.BeginScope()) + await engine.Resolve().ExecuteRunAsync(outcome.RunId, CancellationToken.None); + using var verify = fixture.BeginScope(); + var run = await verify.Resolve().WorkflowRun.AsNoTracking().SingleAsync(candidate => candidate.Id == outcome.RunId); + run.Status.ShouldBe(WorkflowRunStatus.Success); + using var outputs = JsonDocument.Parse(run.OutputsJson); + outputs.RootElement.GetProperty("allowed").GetBoolean().ShouldBeTrue(); + outputs.RootElement.GetProperty("untyped").GetProperty("value").GetString().ShouldBe("kept"); + } + + [Fact] + public async Task The_real_editor_schema_can_be_saved_and_enforced_at_ingress() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !Directory.Exists(Path.Combine(root.FullName, "frontend"))) root = root.Parent; + root.ShouldNotBeNull(); + var path = Path.Combine(root.FullName, "tmp", "external-trigger-schema-fixture.json"); + File.Exists(path).ShouldBeTrue("run frontend external-trigger-schema-contract.test.ts first"); + using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path)); + var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); + using var scope = fixture.BeginScopeAs(userId, teamId); + var config = JsonSerializer.SerializeToElement(new { inputSchema = schema.RootElement }); + var definition = WorkflowsTestSeed.MinimalDefinition() with + { + Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = config, Inputs = WorkflowsTestSeed.EmptyJson() }, + new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }], + }; + var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand + { + Name = "Editor contract", Definition = definition, + Activations = [new WorkflowActivationInput { TypeKey = "trigger.external", Config = config }], + }); + var row = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == workflowId); + var key = scope.Resolve().Open(row.KeyCiphertext); + const string valid = """{"text":"short","paragraph":"paragraph","select":"a","number":1.5,"boolean":true,"repository":"repo","conversation":"chat"}"""; + (await Deliver(valid)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); + (await Deliver(valid.Replace("short", "toolong"))).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + (await Deliver(valid.Replace("\"select\":\"a\"", "\"select\":\"unknown\""))).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + (await Deliver("{}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); + + async Task Deliver(string body) + { + using var receive = fixture.BeginScope(); + return await receive.Resolve().ReceiveAsync(new(row.Id, key, null, body, false), CancellationToken.None); + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs index 67af1e946..7559a4f3d 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerStorageMigrationTests.cs @@ -15,24 +15,34 @@ namespace CodeSpace.IntegrationTests.Workflows; public sealed class ExternalTriggerStorageMigrationTests(PostgresFixture fixture) { [Fact] - public async Task The_slug_is_unique_instance_wide_while_live() + public async Task A_trigger_is_persisted_without_a_slug() { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); var workflowId = await SeedWorkflowAsync(teamId, userId); - var slug = "prod-alerts-" + Guid.NewGuid().ToString("N"); - await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true)); - - await Should.ThrowAsync(() => SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true))); + var row = NewRow(teamId, workflowId, userId, live: true); + await SeedAsync(row); + using var scope = fixture.BeginScope(); + var db = scope.Resolve(); + var saved = await db.WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.Id == row.Id); + saved.KeyDigest.ShouldBe(row.KeyDigest); + saved.KeyCiphertext.ShouldBe(row.KeyCiphertext); + (await db.Database.SqlQuery($"SELECT count(*)::int AS \"Value\" FROM information_schema.columns WHERE table_name = 'workflow_external_trigger' AND column_name = 'slug'").SingleAsync()).ShouldBe(0); } [Fact] - public async Task A_soft_deleted_slug_is_reusable() + public async Task A_new_trigger_does_not_reuse_the_retired_identity() { var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture); var workflowId = await SeedWorkflowAsync(teamId, userId); - var slug = "prod-alerts-" + Guid.NewGuid().ToString("N"); - await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: false)); - await SeedAsync(NewRow(teamId, workflowId, userId, slug, live: true)); + var retired = NewRow(teamId, workflowId, userId, live: false); + var replacement = NewRow(teamId, workflowId, userId, live: true); + await SeedAsync(retired); + await SeedAsync(replacement); + replacement.Id.ShouldNotBe(retired.Id); + using var scope = fixture.BeginScope(); + var rows = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().Where(t => t.WorkflowId == workflowId).ToArrayAsync(); + rows.Single(t => t.Id == retired.Id).DeletedDate.ShouldNotBeNull(); + rows.Single(t => t.Id == replacement.Id).DeletedDate.ShouldBeNull(); } private async Task SeedWorkflowAsync(Guid teamId, Guid userId) @@ -54,9 +64,9 @@ private async Task SeedAsync(WorkflowExternalTrigger row) await scope.Resolve().SaveChangesAsync(); } - private static WorkflowExternalTrigger NewRow(Guid teamId, Guid workflowId, Guid userId, string slug, bool live) => new() + private static WorkflowExternalTrigger NewRow(Guid teamId, Guid workflowId, Guid userId, bool live) => new() { - Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "notify", Slug = slug, + Id = Guid.NewGuid(), TeamId = teamId, WorkflowId = workflowId, NodeId = "notify", KeyDigest = new string('a', 64), KeyCiphertext = "sealed", CreatedDate = DateTimeOffset.UtcNow, CreatedBy = userId, LastModifiedDate = DateTimeOffset.UtcNow, LastModifiedBy = userId, DeletedDate = live ? null : DateTimeOffset.UtcNow, diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs index 484d5141c..20ac1bd7f 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/FailureTaxonomyTests.cs @@ -175,6 +175,8 @@ public void Every_declared_code_is_reachable() // ProviderApiException reports provider_error, and only a real 401/403 from a provider // produces provider_unauthorized. A probe sees one side of a conditional Code, never both. .Concat(new[] { FailureCodes.Unauthorized, FailureCodes.NotFound, FailureCodes.DuplicateResource, FailureCodes.InvalidRequest, FailureCodes.Internal, FailureCodes.ProviderUnauthorized }) + // External-trigger outcomes are returned, not thrown; HTTP tests exercise these three producers. + .Concat(new[] { FailureCodes.ExternalTriggerBodyTooLarge, FailureCodes.ExternalTriggerCompletionRefused, FailureCodes.ExternalTriggerIdempotencyConflict }) .ToHashSet(StringComparer.Ordinal); var unreachable = DeclaredCodes().Where(c => !emitted.Contains(c)).OrderBy(c => c, StringComparer.Ordinal).ToList(); diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs index 34897ec9c..dff861763 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/RequestAuthorizationInventoryTests.cs @@ -36,7 +36,7 @@ public class RequestAuthorizationInventoryTests ["ReceiveWebhookCommand"] = "provider-signed inbound; proof is the HMAC verified against the per-webhook secret.", ["ReceiveConnectionWebhookCommand"] = "the same inbound, from a group/organization hook; proof is the HMAC verified against THAT hook's own secret, never a repository's.", ["ReceiveExternalTriggerCommand"] = "an anonymous external trigger delivery; proof is the per-node key, checked against the trigger row's key digest.", - ["VerifyExternalTriggerCommand"] = "the anonymous identity check before an external trigger body is read; proof is the same per-node key, checked against the trigger row's key digest.", + ["VerifyExternalTriggerQuery"] = "the anonymous identity check before an external trigger body is read; proof is the same per-node key, checked against the trigger row's key digest.", ["ResumeWorkflowCallbackCommand"] = "proof is the single-use high-entropy callback token in the URL.", ["WebhookExistsQuery"] = "asked before a delivery's body is read; it answers only whether the hook id names a hook, which the delivery's own 404 already tells the caller.", ["CompleteCredentialOAuthCommand"] = "the provider redirect carries no JWT; proof is the one-time OAuthPendingState row together with the nonce cookie only the starting browser holds. Without it nothing is consumed: the callback only hands the code to the SPA's completion page, where CompleteCredentialOAuthAsInitiatorCommand requires the signed-in starter.", diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs index 727539610..4b2082882 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/ScopedTransactionInventoryTests.cs @@ -43,6 +43,8 @@ public class ScopedTransactionInventoryTests "Agents/AgentRunSpoolReaper.cs", "Workflows/Budget/BudgetLedger.cs", "Workflows/Engine/RunGenerationFence.cs", + "Workflows/ExternalTriggers/ExternalTriggerIngress.cs", + "Workflows/ExternalTriggers/ExternalTriggerStore.cs", "Workflows/ModelCalls/WorkflowRunModelCallProjector.cs", "Workflows/ToolCalls/WorkflowRunToolCallProjector.cs", "Workflows/WorkflowService.cs", diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs index 1101d9d43..31c8ac418 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerInputProjectionTests.cs @@ -59,9 +59,16 @@ public void A_node_without_object_properties_declares_nothing(string config) } [Fact] - public void A_property_with_no_type_is_skipped_not_fabricated() => - ExternalTriggerInputProjection.Apply([], DefinitionWith( - """{ "inputSchema": { "type": "object", "properties": { "x": { } } } }""")).ShouldBeEmpty(); + public void Properties_with_boolean_or_untyped_schemas_are_projected_as_inputs() + { + var inputs = ExternalTriggerInputProjection.Apply([], DefinitionWith( + """{ "inputSchema": { "type": "object", "properties": { "allowed": true, "blocked": false, "untyped": { } } } }""")); + + inputs.Select(input => input.Name).ShouldBe(["allowed", "blocked", "untyped"]); + inputs[0].Schema.ValueKind.ShouldBe(JsonValueKind.True); + inputs[1].Schema.ValueKind.ShouldBe(JsonValueKind.False); + inputs[2].Schema.ValueKind.ShouldBe(JsonValueKind.Object); + } [Fact] public void The_node_schema_replaces_stale_inputs_and_preserves_the_full_property_schema() diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs new file mode 100644 index 000000000..da4398ab2 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerJsonEqualityTests.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerJsonEqualityTests +{ + [Theory] + [InlineData("{\"a\":1,\"b\":\"x\"}", "{\"b\":\"\\u0078\",\"a\":1.0}", true)] + [InlineData("1", "1e0", true)] + [InlineData("1e-330", "0", false)] + [InlineData("9007199254740992.1", "9007199254740992", false)] + [InlineData("12345678901234567890.01", "1234567890123456789001e-2", true)] + [InlineData("[1,2]", "[2,1]", false)] + [InlineData("\"1\"", "1", false)] + [InlineData("\"A\"", "\"a\"", false)] + public void Json_content_is_compared_without_numeric_rounding(string left, string right, bool equivalent) + { + using var a = JsonDocument.Parse(left); + using var b = JsonDocument.Parse(right); + ExternalTriggerJsonEquality.AreEquivalent(a.RootElement, b.RootElement).ShouldBe(equivalent); + } + + [Fact] + public void Wide_object_member_scans_consume_the_comparison_budget() + { + var properties = string.Join(',', Enumerable.Range(0, 50).Select(i => $"\"p{i}\":{i}")); + using var left = JsonDocument.Parse("{" + properties + "}"); + using var right = JsonDocument.Parse("{" + properties + "}"); + + ExternalTriggerJsonEquality.AreEquivalent(left.RootElement, right.RootElement, 100, out var visited, out var exhausted) + .ShouldBeFalse(); + exhausted.ShouldBeTrue(); + visited.ShouldBe(100); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs index 7efe6e715..3af721c70 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerPayloadValidatorTests.cs @@ -21,6 +21,96 @@ public void A_conforming_body_passes() Schema("""{ "service": "checkout", "count": 3 }"""), Schema(Shape)).ShouldBeEmpty(); } + [Theory] + [InlineData("{\"a\":1,\"a\":2}")] + [InlineData("{\"a\\u0000\":1}")] + [InlineData("{\"a\":\"\\u0000\"}")] + [InlineData("{\"\\uD800\":1}")] + [InlineData("{\"a\":1e-331}")] + [InlineData("{\"a\":0e331}")] + public void Unsafe_payloads_are_rejected_before_schema_checks(string body) => + ExternalTriggerPayloadValidator.Validate(Schema(body), Schema("{\"type\":\"object\"}")).ShouldNotBeEmpty(); + + [Fact] + public void Representability_and_schema_have_independent_node_budgets() + { + var body = "[" + string.Join(",", Enumerable.Repeat("1", 9999)) + "]"; + ExternalTriggerPayloadValidator.Validate(Schema(body), Schema("{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}")).ShouldBeEmpty(); + } + + [Theory] + [InlineData("{\"type\":\"object\",\"required\":3}")] + [InlineData("{\"type\":\"object\",\"enum\":[]}")] + [InlineData("{\"type\":\"object\",\"properties\":[]}")] + [InlineData("{\"type\":\"object\",\"items\":7}")] + [InlineData("{\"type\":\"object\",\"additionalProperties\":7}")] + [InlineData("{\"type\":\"object\",\"maxLength\":-1}")] + [InlineData("{\"type\":\"object\",\"description\":1}")] + [InlineData("{\"type\":\"object\",\"type\":\"object\"}")] + [InlineData("[]")] + public void Malformed_schema_shapes_cannot_be_saved(string schema) => + ExternalTriggerPayloadValidator.ValidateSchema(Schema(schema)).ShouldNotBeEmpty(); + + [Fact] + public void Editor_annotations_are_accepted() => ExternalTriggerPayloadValidator.ValidateSchema(Schema( + "{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\",\"x-long\":true,\"x-hidden\":true,\"x-selector\":\"repository\"}}}")).ShouldBeEmpty(); + + [Theory] + [InlineData("{\"type\":\"object\",\"additionalProperties\":{\"type\":\"string\"}}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"a\":false}}")] + public void Additional_properties_and_boolean_schemas_are_enforced(string schema) => + ExternalTriggerPayloadValidator.Validate(Schema("{\"a\":1}"), Schema(schema)).ShouldNotBeEmpty(); + + [Fact] + public void Numeric_enum_comparison_does_not_round_large_fractional_values() => + ExternalTriggerPayloadValidator.Validate(Schema("9007199254740992.1"), + Schema("{\"type\":\"number\",\"enum\":[9007199254740992]}")).ShouldNotBeEmpty(); + + [Theory] + [InlineData(256, true)] + [InlineData(257, false)] + public void Number_token_length_is_bounded_before_integer_validation(int length, bool valid) + { + var number = "1" + new string('0', length - 1); + (ExternalTriggerPayloadValidator.Validate(Schema(number), Schema("{\"type\":\"integer\"}")).Count == 0).ShouldBe(valid); + } + + [Theory] + [InlineData("1e-330", true)] + [InlineData("0e330", true)] + [InlineData("1e-331", false)] + [InlineData("0e331", false)] + [InlineData("1e000000000000000000000000000001", true)] + public void Explicit_exponent_values_have_a_bounded_range(string token, bool valid) => + (ExternalTriggerPayloadValidator.Validate(Schema(token), Schema("{\"type\":\"number\"}")).Count == 0).ShouldBe(valid); + + [Fact] + public void A_payload_over_the_node_budget_is_refused() + { + var body = "[" + string.Join(",", Enumerable.Repeat("1", 10_000)) + "]"; + ExternalTriggerPayloadValidator.ValidatePayload(Schema(body)).ShouldContain(e => e.Contains("too many nodes")); + } + + [Fact] + public void Schema_errors_are_also_bounded() + { + var properties = string.Join(",", Enumerable.Range(0, 100).Select(i => $"\"x{i}\":{{\"unsupported\":true}}")); + ExternalTriggerPayloadValidator.ValidateSchema(Schema($"{{\"type\":\"object\",\"properties\":{{{properties}}}}}")) + .Count.ShouldBeLessThanOrEqualTo(20); + } + + [Fact] + public void Enum_comparisons_have_a_bounded_total_work_budget() + { + const int enumSize = 6_000; + var values = string.Join(",", Enumerable.Range(0, enumSize)); + var payload = "[" + string.Join(",", Enumerable.Repeat("-1", enumSize)) + "]"; + var schema = "{\"type\":\"array\",\"items\":{\"type\":\"integer\",\"enum\":[" + values + "]}}"; + var errors = ExternalTriggerPayloadValidator.Validate(Schema(payload), Schema(schema)); + errors.ShouldContain(e => e.Contains("enum comparison budget")); + errors.Count.ShouldBeLessThanOrEqualTo(ExternalTriggerPayloadValidator.MaxErrors); + } + [Fact] public void A_missing_required_field_is_named() { diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs new file mode 100644 index 000000000..a0aa232ef --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerRunAdmissionTests.cs @@ -0,0 +1,47 @@ +using CodeSpace.Core.Services.Workflows.ExternalTriggers; +using Shouldly; + +namespace CodeSpace.UnitTests.Workflows; + +[Trait("Category", "Unit")] +public sealed class ExternalTriggerRunAdmissionTests +{ + [Fact] + public void Only_committed_reservations_consume_the_window() + { + using var admission = new ExternalTriggerRunAdmission(); + var id = Guid.NewGuid(); + for (var i = 0; i < 100; i++) + { + using var released = admission.Reserve(id); + released.ShouldNotBeNull(); + } + for (var i = 0; i < 60; i++) + { + using var committed = admission.Reserve(id); + committed.ShouldNotBeNull(); + committed.Commit(); + } + admission.Reserve(id).ShouldBeNull(); + admission.Reserve(Guid.NewGuid()).ShouldNotBeNull(); + } + + [Fact] + public void A_late_release_cannot_refund_a_different_window() + { + var clock = new Clock(); + using var admission = new ExternalTriggerRunAdmission(clock); + var id = Guid.NewGuid(); + using var old = admission.Reserve(id); + clock.Now += TimeSpan.FromMinutes(1); + for (var i = 0; i < 60; i++) admission.Reserve(id)!.Commit(); + old!.Release(); + admission.Reserve(id).ShouldBeNull(); + } + + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch; + public override DateTimeOffset GetUtcNow() => Now; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs deleted file mode 100644 index a73ffdd9a..000000000 --- a/backend/tests/CodeSpace.UnitTests/Workflows/ExternalTriggerSlugTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using CodeSpace.Core.Services.Workflows.ExternalTriggers; -using Shouldly; - -namespace CodeSpace.UnitTests.Workflows; - -[Trait("Category", "Unit")] -public class ExternalTriggerSlugTests -{ - [Fact] - public void Type_key_is_the_external_trigger_identifier() => - ExternalTriggerSlug.TypeKey.ShouldBe("trigger.external"); - - [Theory] - [InlineData("prod 错误 报警", "prod")] - [InlineData("../../etc/passwd", "etc-passwd")] - [InlineData("Prod-Alerts", "prod-alerts")] - [InlineData("日本語", "")] - public void Derive_normalises_author_input(string input, string expected) => - ExternalTriggerSlug.Derive(input).ShouldBe(expected); - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - public void An_unusable_name_is_not_a_slug(string? derived) => - ExternalTriggerSlug.IsUsable(derived).ShouldBeFalse(); - - [Fact] - public void A_nonblank_derived_slug_is_usable() => - ExternalTriggerSlug.IsUsable("prod-alerts").ShouldBeTrue(); -} diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs index db581a84e..2d434af92 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/TriggerExternalNodeTests.cs @@ -7,11 +7,10 @@ namespace CodeSpace.UnitTests.Workflows; public class TriggerExternalNodeTests { [Fact] - public void The_manifest_declares_a_slug_and_an_input_schema_for_the_editor() + public void The_manifest_declares_an_input_schema_without_a_route_name() { var config = new TriggerExternalNode().Manifest.ConfigSchema; - config.GetProperty("properties").GetProperty("slug").GetProperty("type").GetString().ShouldBe("string"); - config.GetProperty("required").EnumerateArray().Any(e => e.GetString() == "slug").ShouldBeTrue(); + config.GetProperty("properties").TryGetProperty("slug", out _).ShouldBeFalse(); config.GetProperty("properties").TryGetProperty("inputSchema", out _).ShouldBeTrue(); } } diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index cdfed56be..61b1d3eff 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -165,7 +165,6 @@ export interface WorkflowExternalTriggerSummary { id: string; workflowId: string; nodeId: string; - slug: string; } export interface WorkflowExternalTriggersResult { @@ -1459,17 +1458,12 @@ export const workflowsApi = { `/api/workflows/external-triggers?${new URLSearchParams({ workflowId })}`, ), - revealExternalTriggerKey: (slug: string) => fetchJson( - `/api/workflows/external-triggers/${encodeURIComponent(slug)}/key`, - ), - - rotateExternalTriggerKey: (slug: string) => fetchJson( - `/api/workflows/external-triggers/${encodeURIComponent(slug)}/rotate-key`, { method: "POST" }, + revealExternalTriggerKey: (triggerId: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(triggerId)}/key`, ), - renameExternalTrigger: (slug: string, desiredSlug: string) => fetchJson( - `/api/workflows/external-triggers/${encodeURIComponent(slug)}/rename`, - { method: "POST", body: JSON.stringify({ desiredSlug }) }, + rotateExternalTriggerKey: (triggerId: string) => fetchJson( + `/api/workflows/external-triggers/${encodeURIComponent(triggerId)}/rotate-key`, { method: "POST" }, ), /** Resolve one workflow by ref — its GUID (legacy link) or team-unique slug (clean URL). */ diff --git a/frontend/src/components/workflows/external-trigger-editor.tsx b/frontend/src/components/workflows/external-trigger-editor.tsx index c329dcfba..2d9360674 100644 --- a/frontend/src/components/workflows/external-trigger-editor.tsx +++ b/frontend/src/components/workflows/external-trigger-editor.tsx @@ -20,6 +20,6 @@ export function ExternalTriggerEditor({ workflowId, nodeId, config, onConfigChan ); - const currentSlug = triggers.data.triggers.find((trigger) => trigger.nodeId === nodeId)?.slug ?? null; - return ; + const currentTriggerId = triggers.data.triggers.find((trigger) => trigger.nodeId === nodeId)?.id ?? null; + return ; } diff --git a/frontend/src/components/workflows/external-trigger-inspector.test.tsx b/frontend/src/components/workflows/external-trigger-inspector.test.tsx index 5c1511faa..4ec9c02c3 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.test.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.test.tsx @@ -9,13 +9,13 @@ import { useUpdateWorkflow, useWorkflowExternalTriggers } from "@/hooks/use-work const REQUESTS = vi.fn(); const CONFIG_CHANGE = vi.fn(); -const SUMMARY = { id: "trigger-1", workflowId: "workflow-1", nodeId: "ext", slug: "prod-alerts" }; +const SUMMARY = { id: "56da5193-d2a0-4309-9ed7-eef395d72124", workflowId: "workflow-1", nodeId: "ext" }; -function renderInspector(currentSlug: string | null = "prod-alerts", slug = "prod-alerts") { +function renderInspector(currentTriggerId: string | null = "56da5193-d2a0-4309-9ed7-eef395d72124", slug = "56da5193-d2a0-4309-9ed7-eef395d72124") { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); function Inspector() { const [config, setConfig] = useState>({ slug, inputSchema: {} }); - return { CONFIG_CHANGE(next); setConfig(next); }} />; + return { CONFIG_CHANGE(next); setConfig(next); }} />; } return render(); } @@ -32,19 +32,19 @@ afterEach(() => vi.unstubAllGlobals()); describe("ExternalTriggerInspector", () => { it("preserves a boolean-false property when another property's label changes", () => { const client = new QueryClient(); - const config = { slug: "prod-alerts", inputSchema: { properties: { blocked: false, message: { type: "string" } } } }; - render(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { properties: { blocked: false, message: { type: "string" } } } }; + render(); fireEvent.click(screen.getByText("message")); fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "Alert message" } }); fireEvent.click(screen.getByRole("button", { name: /^save$/i })); - expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "prod-alerts", inputSchema: { type: "object", properties: { blocked: false, message: { type: "string", title: "Alert message" } }, required: [] } }); + expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", properties: { blocked: false, message: { type: "string", title: "Alert message" } }, required: [] } }); expect(CONFIG_CHANGE.mock.lastCall?.[0].inputSchema.properties.blocked).toBe(false); }); it("preserves a boolean constraint and warns when its display label cannot be applied", () => { const client = new QueryClient(); - const config = { slug: "prod-alerts", inputSchema: { properties: { blocked: false } } }; - render(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { properties: { blocked: false } } }; + render(); fireEvent.click(screen.getByText("blocked")); fireEvent.change(screen.getByPlaceholderText("blocked"), { target: { value: "Blocked field" } }); fireEvent.click(screen.getByRole("button", { name: /^save$/i })); @@ -56,16 +56,16 @@ describe("ExternalTriggerInspector", () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); renderInspector(); - expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(screen.getByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); expect(REQUESTS).not.toHaveBeenCalled(); expect(screen.queryByDisplayValue(/^ext_/)).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /show key/i })); expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); - expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/key"), expect.anything()); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/56da5193-d2a0-4309-9ed7-eef395d72124/key"), expect.anything()); fireEvent.click(screen.getByRole("button", { name: /copy key/i })); await waitFor(() => expect(writeText).toHaveBeenCalledWith("ext_secret")); fireEvent.click(screen.getByRole("button", { name: /copy curl/i })); - await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/prod-alerts/run"))); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(expect.stringContaining("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run"))); expect(writeText).toHaveBeenCalledWith(expect.stringContaining("curl -X POST")); expect(writeText).toHaveBeenCalledWith(expect.stringContaining("Authorization: Bearer ext_secret")); expect(writeText).not.toHaveBeenCalledWith(expect.stringContaining("?key=")); @@ -82,38 +82,14 @@ describe("ExternalTriggerInspector", () => { fireEvent.click(screen.getByRole("button", { name: /regenerate/i })); fireEvent.click(within(screen.getByRole("group")).getByRole("button", { name: /confirm/i })); expect(await screen.findByDisplayValue("ext_secret")).toBeInTheDocument(); - expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/rotate-key"), expect.objectContaining({ method: "POST" })); + expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/56da5193-d2a0-4309-9ed7-eef395d72124/rotate-key"), expect.objectContaining({ method: "POST" })); }); - it("keeps live and draft names distinct and explicitly confirms rename using desiredSlug", async () => { - renderInspector("prod-alerts", "New Alerts"); - expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); - expect(screen.getByText(/saving.*does not rename/i)).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: /^rename/i })); - const group = screen.getByRole("group", { name: /confirm.*rename/i }); - expect(within(group).getByText(/existing url will stop working/i)).toBeInTheDocument(); - expect(REQUESTS).not.toHaveBeenCalled(); - fireEvent.click(within(group).getByRole("button", { name: /confirm/i })); - await waitFor(() => expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/prod-alerts/rename"), expect.objectContaining({ method: "POST", body: JSON.stringify({ desiredSlug: "new-alerts" }) }))); - }); - - it("refuses an all-CJK draft without a fallback and disables rename", () => { - renderInspector(); - fireEvent.change(screen.getByRole("textbox", { name: /draft slug/i }), { target: { value: "日本語" } }); - expect(screen.getByRole("alert")).toHaveTextContent(/use an english/i); - expect(screen.getByRole("button", { name: /^rename/i })).toBeDisabled(); - expect(CONFIG_CHANGE).toHaveBeenLastCalledWith({ slug: "日本語", inputSchema: {} }); - expect(screen.getByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); - }); - - it("explains an unusable draft slug in an already-open rename confirmation instead of naming an empty target", () => { - renderInspector("prod-alerts", "New Alerts"); - fireEvent.click(screen.getByRole("button", { name: /^rename/i })); - fireEvent.change(screen.getByRole("textbox", { name: /draft slug/i }), { target: { value: "!!!" } }); - const group = screen.getByRole("group", { name: /confirm.*rename/i }); - expect(within(group).getByText(/use an english/i)).toBeInTheDocument(); - expect(within(group).queryByText(/existing url will stop working/i)).not.toBeInTheDocument(); - expect(within(group).getByRole("button", { name: /confirm/i })).toBeDisabled(); + it("uses the server identity without offering route edits", () => { + renderInspector("56da5193-d2a0-4309-9ed7-eef395d72124", "New Alerts"); + expect(screen.getByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); + expect(screen.queryByRole("textbox", { name: /slug/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^rename/i })).not.toBeInTheDocument(); expect(REQUESTS).not.toHaveBeenCalled(); }); @@ -121,7 +97,7 @@ describe("ExternalTriggerInspector", () => { renderInspector(null); expect(screen.getByText(/save the workflow to create this endpoint/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /show key|regenerate|rename/i })).not.toBeInTheDocument(); - expect(screen.queryByText("/api/ext/prod-alerts/run")).not.toBeInTheDocument(); + expect(screen.queryByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).not.toBeInTheDocument(); }); it("reports management errors without showing a key", async () => { @@ -142,29 +118,23 @@ describe("ExternalTriggerInspector", () => { it("preserves schema constraints and can clear the display name while editing", async () => { const client = new QueryClient(); - const config = { slug: "prod-alerts", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", title: "Alert message", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } }; - render(); + const config = { slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", title: "Alert message", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } }; + render(); fireEvent.click(screen.getByTitle("Edit field")); fireEvent.change(screen.getByPlaceholderText("message"), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: /^save$/i })); - await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith({ slug: "prod-alerts", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } })); + await waitFor(() => expect(CONFIG_CHANGE).toHaveBeenCalledWith({ slug: "56da5193-d2a0-4309-9ed7-eef395d72124", inputSchema: { type: "object", additionalProperties: false, properties: { message: { type: "string", pattern: "^[a-z]+$", description: "Alert text" } }, required: ["message"] } })); }); }); describe("ExternalTriggerEditor live metadata", () => { - it("reads the live slug by nodeId, refreshes after rename, and never uses the draft as live identity", async () => { - let liveSlug = "prod-alerts"; - REQUESTS.mockImplementation(async (url) => { - if (String(url).endsWith("/rename")) { liveSlug = "draft"; return new Response(null, { status: 204 }); } - return new Response(JSON.stringify({ triggers: [{ ...SUMMARY, nodeId: "other", slug: "other" }, { ...SUMMARY, slug: liveSlug }] }), { status: 200 }); - }); + it("reads the live UUID by nodeId and never uses the draft as identity", async () => { + REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [{ ...SUMMARY, id: "other", nodeId: "other" }, SUMMARY] }), { status: 200 })); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); - expect(await screen.findByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(await screen.findByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); expect(REQUESTS).toHaveBeenCalledWith(expect.stringContaining("/api/workflows/external-triggers?workflowId=workflow-1"), expect.anything()); - fireEvent.click(screen.getByRole("button", { name: /^rename/i })); - fireEvent.click(within(screen.getByRole("group")).getByRole("button", { name: /confirm/i })); - expect(await screen.findByText("/api/ext/draft/run")).toBeInTheDocument(); + expect(screen.queryByText("/api/ext/draft/run")).not.toBeInTheDocument(); }); it("treats an empty list as unsaved, not as a draft-backed endpoint", async () => { @@ -182,7 +152,7 @@ describe("ExternalTriggerEditor live metadata", () => { expect(screen.queryByText(/save the workflow/i)).not.toBeInTheDocument(); REQUESTS.mockResolvedValue(new Response(JSON.stringify({ triggers: [SUMMARY] }), { status: 200 })); fireEvent.click(screen.getByRole("button", { name: /retry/i })); - expect(await screen.findByText("/api/ext/prod-alerts/run")).toBeInTheDocument(); + expect(await screen.findByText("/api/ext/56da5193-d2a0-4309-9ed7-eef395d72124/run")).toBeInTheDocument(); }); it("refreshes live metadata after saving a newly added node", async () => { diff --git a/frontend/src/components/workflows/external-trigger-inspector.tsx b/frontend/src/components/workflows/external-trigger-inspector.tsx index 907c263fb..7a600c14a 100644 --- a/frontend/src/components/workflows/external-trigger-inspector.tsx +++ b/frontend/src/components/workflows/external-trigger-inspector.tsx @@ -2,37 +2,29 @@ import { useState } from "react"; import { Ic } from "@/_imported/ai-code-space/icons"; import type { WorkflowVariable } from "@/api/workflows"; -import { useRenameExternalTrigger, useRevealExternalTriggerKey, useRotateExternalTriggerKey } from "@/hooks/use-workflows"; -import { deriveExternalTriggerSlug, isUsableExternalTriggerSlug } from "@/lib/external-trigger-slug"; +import { useRevealExternalTriggerKey, useRotateExternalTriggerKey } from "@/hooks/use-workflows"; import { externalTriggerUrl } from "@/lib/external-trigger-url"; import { StartNodeInputsEditor } from "./StartNodeInputsEditor"; interface IExternalTriggerInspectorProps { config: Record; - nodeId: string; - currentSlug: string | null; + currentTriggerId: string | null; onConfigChange: (next: Record) => void; } -const UNUSABLE_SLUG_GUIDANCE = "Cannot derive a usable slug. Use an English name with ASCII letters, digits or underscores."; - function asObject(value: unknown): Record { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } -export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfigChange }: IExternalTriggerInspectorProps) { +export function ExternalTriggerInspector({ config, currentTriggerId, onConfigChange }: IExternalTriggerInspectorProps) { const reveal = useRevealExternalTriggerKey(); const rotate = useRotateExternalTriggerKey(); - const rename = useRenameExternalTrigger(); - const [confirming, setConfirming] = useState<"rotate" | "rename" | null>(null); + const [confirming, setConfirming] = useState(false); const [key, setKey] = useState(null); const [copyStatus, setCopyStatus] = useState(null); const [copyError, setCopyError] = useState(null); const [schemaWarning, setSchemaWarning] = useState(null); - const draft = typeof config.slug === "string" ? config.slug : ""; - const derived = deriveExternalTriggerSlug(draft); - const usable = isUsableExternalTriggerSlug(derived); const schema = asObject(config.inputSchema); const properties = asObject(schema.properties); const required = Array.isArray(schema.required) ? schema.required : []; @@ -40,9 +32,9 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig const property = asObject(value); return { name, schema: value, label: typeof property.title === "string" ? property.title : undefined, default: property.default, required: required.includes(name) }; }); - const pending = reveal.isPending || rotate.isPending || rename.isPending; - const error = reveal.error ?? rotate.error ?? rename.error; - const endpoint = currentSlug ? `/api/ext/${encodeURIComponent(currentSlug)}/run` : null; + const pending = reveal.isPending || rotate.isPending; + const error = reveal.error ?? rotate.error; + const endpoint = currentTriggerId ? `/api/ext/${encodeURIComponent(currentTriggerId)}/run` : null; const fullUrl = endpoint ? externalTriggerUrl(endpoint) : ""; const curl = key ? `curl -X POST '${fullUrl}' -H 'Authorization: Bearer ${key}' -H 'Content-Type: application/json' -d '{}'` : null; @@ -76,38 +68,22 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig }; const requestKey = () => { - if (!currentSlug || pending) return; + if (!currentTriggerId || pending) return; reveal.reset(); rotate.reset(); - rename.reset(); - reveal.mutate(currentSlug, { onSuccess: (result) => setKey(result.key) }); + reveal.mutate(currentTriggerId, { onSuccess: (result) => setKey(result.key) }); }; const confirm = () => { - if (!currentSlug || pending) return; + if (!currentTriggerId || pending) return; reveal.reset(); rotate.reset(); - rename.reset(); - if (confirming === "rotate") { - setKey(null); - rotate.mutate(currentSlug, { onSuccess: (result) => { setKey(result.key); setConfirming(null); } }); - } else if (confirming === "rename" && usable && derived !== currentSlug) { - rename.mutate({ slug: currentSlug, desiredSlug: derived }, { onSuccess: () => { setKey(null); setConfirming(null); } }); - } + setKey(null); + rotate.mutate(currentTriggerId, { onSuccess: (result) => { setKey(result.key); setConfirming(false); } }); }; return ( <> -
- -

- {usable ? `Derived slug: ${derived}` : UNUSABLE_SLUG_GUIDANCE} -

-
- {schemaWarning &&

{schemaWarning}

} @@ -115,23 +91,19 @@ export function ExternalTriggerInspector({ config, nodeId, currentSlug, onConfig

Endpoint

{!endpoint ?

Save the workflow to create this endpoint.

: ( <> -
Live slug{currentSlug}
+
Trigger ID{currentTriggerId}
{endpoint} - {draft !== currentSlug &&

The draft differs from the live slug. Saving the workflow does not rename this endpoint.

}
- - +
{confirming && ( -
-

{confirming === "rotate" - ? "The old key stops working immediately. Existing callers must use the new key." - : usable ? `The existing URL will stop working. Rename the live endpoint to ${derived}?` : UNUSABLE_SLUG_GUIDANCE}

+
+

The old key stops working immediately. Existing callers must use the new key.

- - + +
)} diff --git a/frontend/src/hooks/use-workflows.ts b/frontend/src/hooks/use-workflows.ts index 154dcfbac..33adb04c8 100644 --- a/frontend/src/hooks/use-workflows.ts +++ b/frontend/src/hooks/use-workflows.ts @@ -69,19 +69,11 @@ export function useWorkflowExternalTriggers(workflowId: string) { } export function useRevealExternalTriggerKey() { - return useMutation({ mutationFn: (slug: string) => workflowsApi.revealExternalTriggerKey(slug), gcTime: 0 }); + return useMutation({ mutationFn: (triggerId: string) => workflowsApi.revealExternalTriggerKey(triggerId), gcTime: 0 }); } export function useRotateExternalTriggerKey() { - return useMutation({ mutationFn: (slug: string) => workflowsApi.rotateExternalTriggerKey(slug), gcTime: 0 }); -} - -export function useRenameExternalTrigger() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: ({ slug, desiredSlug }: { slug: string; desiredSlug: string }) => workflowsApi.renameExternalTrigger(slug, desiredSlug), - onSuccess: () => qc.invalidateQueries({ queryKey: ["workflow-external-triggers"] }), - }); + return useMutation({ mutationFn: (triggerId: string) => workflowsApi.rotateExternalTriggerKey(triggerId), gcTime: 0 }); } export function useDeleteWorkflow() { diff --git a/frontend/src/lib/external-trigger-schema-contract.test.ts b/frontend/src/lib/external-trigger-schema-contract.test.ts new file mode 100644 index 000000000..9f5e0f858 --- /dev/null +++ b/frontend/src/lib/external-trigger-schema-contract.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment node +/// +import { mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +import { buildFieldSchema, type InputFieldDraft } from "./inputFieldSchema"; + +it("exports all seven real editor field schemas for the backend contract", () => { + const drafts: Record = { + text: { type: "text", maxLength: 5, hidden: true }, + paragraph: { type: "paragraph", maxLength: 20 }, + select: { type: "select", options: ["a", "b"] }, + number: { type: "number" }, + boolean: { type: "boolean" }, + repository: { type: "repository" }, + conversation: { type: "conversation" }, + }; + const properties = Object.fromEntries(Object.entries(drafts).map(([name, draft]) => [name, buildFieldSchema(draft)])); + const schema = { type: "object", properties, required: Object.keys(properties), additionalProperties: false }; + expect(properties.text).toEqual({ type: "string", maxLength: 5, "x-hidden": true }); + expect(properties.paragraph).toEqual({ type: "string", maxLength: 20, "x-long": true }); + expect(properties.select).toEqual({ type: "string", enum: ["a", "b"] }); + const destination = fileURLToPath(new URL("../../../tmp/", import.meta.url)); + mkdirSync(destination, { recursive: true }); + writeFileSync(`${destination}/external-trigger-schema-fixture.json`, JSON.stringify(schema)); +}); diff --git a/frontend/src/lib/external-trigger-slug.test.ts b/frontend/src/lib/external-trigger-slug.test.ts deleted file mode 100644 index c78ec86ba..000000000 --- a/frontend/src/lib/external-trigger-slug.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { deriveExternalTriggerSlug, isUsableExternalTriggerSlug } from "./external-trigger-slug"; - -describe("external trigger slug parity with backend golden vectors", () => { - it.each([ - ["Prod-Alerts", "prod-alerts"], - ["日本語", ""], - ["prod 错误 报警", "prod"], - ["../../etc/passwd", "etc-passwd"], - [" Padded Name ", "padded-name"], - ["snake_case_kept", "snake_case_kept"], - ["café münch", "caf-m-nch"], - ["---spaces---", "spaces"], - ["$$$", ""], - ["a".repeat(63) + " b", "a".repeat(63)], - ["A".repeat(80), "a".repeat(64)], - ])("derives %s as %s", (input, expected) => { - expect(deriveExternalTriggerSlug(input)).toBe(expected); - }); - - it.each(["", " ", "UPPER", "日本語", "a/b", "a".repeat(65)])("rejects unusable slug %s", (slug) => { - expect(isUsableExternalTriggerSlug(slug)).toBe(false); - }); - - it.each(["prod-alerts", "snake_case", "0", "a".repeat(64)])("accepts %s", (slug) => { - expect(isUsableExternalTriggerSlug(slug)).toBe(true); - }); -}); diff --git a/frontend/src/lib/external-trigger-slug.ts b/frontend/src/lib/external-trigger-slug.ts deleted file mode 100644 index fb3fc2757..000000000 --- a/frontend/src/lib/external-trigger-slug.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { deriveSlug } from "@/components/agents/deriveSlug"; - -/** Shares the canonical ASCII slug derivation; empty names never receive a fallback. */ -export function deriveExternalTriggerSlug(name: string): string { - return deriveSlug(name); -} - -export function isUsableExternalTriggerSlug(slug: string): boolean { - return /^[a-z0-9_-]{1,64}$/.test(slug); -} From 21c2f1bef0ff5ecffc7ec1e986968e10d246353a Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Tue, 27 Oct 2026 08:44:16 +0800 Subject: [PATCH 26/26] Remove cross-stack schema test fixture dependency --- .../ExternalTriggerSchemaContractFlowTests.cs | 37 ------------------- .../external-trigger-schema-contract.test.ts | 27 -------------- 2 files changed, 64 deletions(-) delete mode 100644 frontend/src/lib/external-trigger-schema-contract.test.ts diff --git a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs index b9e2f9491..2e956b371 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerSchemaContractFlowTests.cs @@ -66,41 +66,4 @@ public async Task Boolean_and_untyped_property_schemas_are_saved_and_resolve_thr outputs.RootElement.GetProperty("allowed").GetBoolean().ShouldBeTrue(); outputs.RootElement.GetProperty("untyped").GetProperty("value").GetString().ShouldBe("kept"); } - - [Fact] - public async Task The_real_editor_schema_can_be_saved_and_enforced_at_ingress() - { - var root = new DirectoryInfo(AppContext.BaseDirectory); - while (root is not null && !Directory.Exists(Path.Combine(root.FullName, "frontend"))) root = root.Parent; - root.ShouldNotBeNull(); - var path = Path.Combine(root.FullName, "tmp", "external-trigger-schema-fixture.json"); - File.Exists(path).ShouldBeTrue("run frontend external-trigger-schema-contract.test.ts first"); - using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path)); - var (teamId, userId) = await WorkflowsTestSeed.SeedTeamAsync(fixture, false); - using var scope = fixture.BeginScopeAs(userId, teamId); - var config = JsonSerializer.SerializeToElement(new { inputSchema = schema.RootElement }); - var definition = WorkflowsTestSeed.MinimalDefinition() with - { - Nodes = [new NodeDefinition { Id = "start", TypeKey = "trigger.external", Config = config, Inputs = WorkflowsTestSeed.EmptyJson() }, - new NodeDefinition { Id = "end", TypeKey = "builtin.terminal", Config = WorkflowsTestSeed.EmptyJson(), Inputs = WorkflowsTestSeed.EmptyJson() }], - }; - var workflowId = await scope.Resolve().Send(new CreateWorkflowCommand - { - Name = "Editor contract", Definition = definition, - Activations = [new WorkflowActivationInput { TypeKey = "trigger.external", Config = config }], - }); - var row = await scope.Resolve().WorkflowExternalTrigger.AsNoTracking().SingleAsync(t => t.WorkflowId == workflowId); - var key = scope.Resolve().Open(row.KeyCiphertext); - const string valid = """{"text":"short","paragraph":"paragraph","select":"a","number":1.5,"boolean":true,"repository":"repo","conversation":"chat"}"""; - (await Deliver(valid)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted); - (await Deliver(valid.Replace("short", "toolong"))).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); - (await Deliver(valid.Replace("\"select\":\"a\"", "\"select\":\"unknown\""))).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); - (await Deliver("{}")).Kind.ShouldBe(ExternalTriggerOutcomeKind.InvalidPayload); - - async Task Deliver(string body) - { - using var receive = fixture.BeginScope(); - return await receive.Resolve().ReceiveAsync(new(row.Id, key, null, body, false), CancellationToken.None); - } - } } diff --git a/frontend/src/lib/external-trigger-schema-contract.test.ts b/frontend/src/lib/external-trigger-schema-contract.test.ts deleted file mode 100644 index 9f5e0f858..000000000 --- a/frontend/src/lib/external-trigger-schema-contract.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @vitest-environment node -/// -import { mkdirSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { expect, it } from "vitest"; - -import { buildFieldSchema, type InputFieldDraft } from "./inputFieldSchema"; - -it("exports all seven real editor field schemas for the backend contract", () => { - const drafts: Record = { - text: { type: "text", maxLength: 5, hidden: true }, - paragraph: { type: "paragraph", maxLength: 20 }, - select: { type: "select", options: ["a", "b"] }, - number: { type: "number" }, - boolean: { type: "boolean" }, - repository: { type: "repository" }, - conversation: { type: "conversation" }, - }; - const properties = Object.fromEntries(Object.entries(drafts).map(([name, draft]) => [name, buildFieldSchema(draft)])); - const schema = { type: "object", properties, required: Object.keys(properties), additionalProperties: false }; - expect(properties.text).toEqual({ type: "string", maxLength: 5, "x-hidden": true }); - expect(properties.paragraph).toEqual({ type: "string", maxLength: 20, "x-long": true }); - expect(properties.select).toEqual({ type: "string", enum: ["a", "b"] }); - const destination = fileURLToPath(new URL("../../../tmp/", import.meta.url)); - mkdirSync(destination, { recursive: true }); - writeFileSync(`${destination}/external-trigger-schema-fixture.json`, JSON.stringify(schema)); -});