-
{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