diff --git a/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs
new file mode 100644
index 000000000..c15eb4c35
--- /dev/null
+++ b/backend/src/CodeSpace.Api/Controllers/ExternalTriggersController.cs
@@ -0,0 +1,82 @@
+using CodeSpace.Api.Extensions;
+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 CodeSpace.Messages.Failures;
+using System.Text.Json;
+
+namespace CodeSpace.Api.Controllers;
+
+///
+/// Anonymous ingress authenticates before reading the bounded body or opening the receive transaction.
+///
+[ApiController]
+[Route("api/ext")]
+[AllowAnonymous]
+[EnableRateLimiting(WebhookIngressRateLimitExtension.PolicyName)]
+public class ExternalTriggersController : ControllerBase
+{
+ private readonly IMediator _mediator;
+
+ public ExternalTriggersController(IMediator mediator) { _mediator = mediator; }
+
+ [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 VerifyExternalTriggerQuery { TriggerId = triggerId, PresentedKey = presentedKey }, cancellationToken).ConfigureAwait(false);
+ if (verification.Kind is ExternalTriggerDeliveryKind.NotFound or ExternalTriggerDeliveryKind.Unauthorized)
+ return Unauthorized(new { code = FailureCodes.Unauthorized, message = "Invalid external trigger credentials." });
+
+ 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
+ {
+ TriggerId = triggerId,
+ PresentedKey = presentedKey,
+ IdempotencyKey = idempotencyKey,
+ IdempotencyRejected = idempotencyRejected,
+ Body = body.RootElement,
+ }, cancellationToken).ConfigureAwait(false);
+
+ return outcome.Kind switch
+ {
+ 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, 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 { 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
new file mode 100644
index 000000000..9695dc8bd
--- /dev/null
+++ b/backend/src/CodeSpace.Api/Controllers/WorkflowExternalTriggersController.cs
@@ -0,0 +1,35 @@
+using CodeSpace.Messages.Commands.Workflows;
+using CodeSpace.Messages.Queries.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]
+ 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("{triggerId:guid}/key")]
+ public async Task Reveal([FromRoute] Guid triggerId, CancellationToken cancellationToken)
+ {
+ Response.Headers.CacheControl = "private, no-store";
+ var key = await mediator.Send(new RevealExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false);
+ return Ok(new { key });
+ }
+
+ [HttpPost("{triggerId:guid}/rotate-key")]
+ public async Task Rotate([FromRoute] Guid triggerId, CancellationToken cancellationToken)
+ {
+ Response.Headers.CacheControl = "private, no-store";
+ var key = await mediator.Send(new RotateExternalTriggerKeyCommand { TriggerId = triggerId }, cancellationToken).ConfigureAwait(false);
+ return Ok(new { key });
+ }
+
+}
diff --git a/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs b/backend/src/CodeSpace.Api/Extensions/WebhookIngressRateLimitExtension.cs
index 362ce3825..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" };
+ 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
new file mode 100644
index 000000000..74f9bb8dd
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Workflows/ExternalTriggerCommandHandlers.cs
@@ -0,0 +1,88 @@
+using CodeSpace.Core.Persistence.Db;
+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;
+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.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)
+ .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.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 ReceiveExternalTriggerCommandHandler(IExternalTriggerIngress ingress)
+ : IRequestHandler
+{
+ public async Task Handle(ReceiveExternalTriggerCommand request, CancellationToken cancellationToken)
+ {
+ var outcome = await ingress.ReceiveAsync(new ExternalTriggerCall(
+ request.TriggerId, request.PresentedKey, request.IdempotencyKey, string.Empty, request.IdempotencyRejected, request.Body),
+ 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.Forbidden => ExternalTriggerDeliveryKind.Forbidden,
+ ExternalTriggerOutcomeKind.Unprocessable => ExternalTriggerDeliveryKind.Unprocessable,
+ ExternalTriggerOutcomeKind.InvalidPayload => ExternalTriggerDeliveryKind.InvalidPayload,
+ ExternalTriggerOutcomeKind.RateLimited => ExternalTriggerDeliveryKind.RateLimited,
+ _ => throw new InvalidOperationException($"Unknown external trigger outcome: {outcome.Kind}"),
+ },
+ RunId = outcome.RunId,
+ Errors = outcome.Errors,
+ Code = outcome.Code,
+ };
+ }
+}
+
+public sealed class VerifyExternalTriggerQueryHandler(IExternalTriggerIngress ingress)
+ : IRequestHandler
+{
+ public async Task Handle(VerifyExternalTriggerQuery request, CancellationToken cancellationToken)
+ {
+ var verification = await ingress.VerifyAsync(request.TriggerId, 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/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Workflows/ListWorkflowExternalTriggersQueryHandler.cs
new file mode 100644
index 000000000..3b9ee2901
--- /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)).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 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..e1d056cdf
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Persistence/DbUpFiles/0251_workflow_external_trigger.sql
@@ -0,0 +1,24 @@
+-- 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,
+ 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 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/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/Persistence/Entities/WorkflowExternalTrigger.cs b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs
new file mode 100644
index 000000000..a1556e078
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Persistence/Entities/WorkflowExternalTrigger.cs
@@ -0,0 +1,16 @@
+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 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..ce5c2f663
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Persistence/EntityConfigurations/WorkflowExternalTriggerConfiguration.cs
@@ -0,0 +1,28 @@
+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.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.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
new file mode 100644
index 000000000..4ccb6d7c7
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerDefinitionSync.cs
@@ -0,0 +1,52 @@
+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 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.
+ ///
+ 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();
+ 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;
+ row.KeyCiphertext = string.Empty;
+ }
+
+ foreach (var node in nodes)
+ await store.EnsureForNodeAsync(workflowId, teamId, node.Id, actorId, cancellationToken).ConfigureAwait(false);
+ }
+}
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..7798efd40
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIdempotency.cs
@@ -0,0 +1,20 @@
+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 '-');
+
+ ///
+ /// 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/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
new file mode 100644
index 000000000..f529d7ef4
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerIngress.cs
@@ -0,0 +1,266 @@
+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.Webhooks;
+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;
+
+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(
+ CodeSpaceDbContext db,
+ IRunStarter runStarter,
+ IWorkflowRunDispatcher runDispatcher,
+ IPostCommitActions postCommit,
+ IIngestionAuditor auditor,
+ ExternalTriggerRunAdmission admission,
+ ExternalTriggerVerificationContext verificationContext,
+ TransactionCompletionCallbacks completion,
+ ILogger logger) : IExternalTriggerIngress
+{
+ public async Task VerifyAsync(Guid triggerId, string? presentedKey, CancellationToken ct)
+ {
+ verificationContext.Ticket = null;
+ var trigger = await db.WorkflowExternalTrigger.AsNoTracking()
+ .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
+ && 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 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 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 Verification(ExternalTriggerOutcomeKind.NotFound);
+ var config = nodes[0].Config;
+
+ var schema = config.ValueKind == JsonValueKind.Object
+ && config.TryGetProperty("inputSchema", out var declared) && declared.ValueKind == JsonValueKind.Object
+ ? declared : default;
+
+ return new ExternalTriggerVerification(
+ ExternalTriggerOutcomeKind.Accepted,
+ trigger.TriggerId,
+ activation.Id,
+ activation.WorkflowId,
+ activation.Workflow.TeamId,
+ activation.Workflow.LatestVersion,
+ ActivationAuthoritySnapshot.Serialize(activation),
+ schema);
+ }
+
+ public async Task ReceiveAsync(ExternalTriggerCall call, CancellationToken ct)
+ {
+ 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 = call.ParsedBody.HasValue ? null : JsonDocument.Parse(call.RawBody); }
+ catch (JsonException) { return InvalidBody(); }
+ using (body)
+ {
+ 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(payload, schema);
+ if (errors.Count != 0) return Outcome(ExternalTriggerOutcomeKind.InvalidPayload, errors: errors);
+
+ var envelope = new RunSourceEnvelope
+ {
+ TeamId = verification.TeamId,
+ WorkflowId = verification.WorkflowId,
+ WorkflowVersion = verification.WorkflowVersion,
+ SourceType = ExternalTriggerSlug.TypeKey,
+ ActorType = WorkflowRunActorTypes.Webhook,
+ ActorId = null,
+ CreatedBy = SystemUsers.SeederId,
+ ActivationId = verification.ActivationId,
+ ActivationSnapshotJson = verification.ActivationSnapshotJson,
+ NormalizedPayloadJson = payload.GetRawText(),
+ IdempotencyKey = idempotencyKey,
+ };
+
+ 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);
+ }
+
+ Guid runId;
+ 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)
+ {
+ 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)
+ {
+ logger.LogWarning("External trigger activation {ActivationId} workflow {WorkflowId} refused: {Reason} {Detail}", verification.ActivationId, verification.WorkflowId, reason, detail);
+ await auditor.WriteWebhookRejectedAsync(new WebhookRejectionContext
+ {
+ TeamId = verification.TeamId,
+ SourceType = ExternalTriggerSlug.TypeKey,
+ ExternalEventId = call.IdempotencyKey,
+ Reason = reason,
+ 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 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
new file mode 100644
index 000000000..2e9046b61
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerInputProjection.cs
@@ -0,0 +1,46 @@
+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 is not (JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False)) continue;
+ var isObject = value.ValueKind == JsonValueKind.Object;
+
+ inputs.Add(new WorkflowVariable
+ {
+ Name = property.Name,
+ Label = isObject && value.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String ? title.GetString() : null,
+ Description = isObject && value.TryGetProperty("description", out var description) && description.ValueKind == JsonValueKind.String ? description.GetString() : null,
+ Schema = value.Clone(),
+ Default = isObject && 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/ExternalTriggers/ExternalTriggerJsonEquality.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs
new file mode 100644
index 000000000..a3b7f7f10
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerJsonEquality.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+
+namespace CodeSpace.Core.Services.Workflows.ExternalTriggers;
+
+public static class ExternalTriggerJsonEquality
+{
+ public static bool AreEquivalent(JsonElement left, JsonElement right) =>
+ 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/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/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs
new file mode 100644
index 000000000..3e5019a9a
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerPayloadValidator.cs
@@ -0,0 +1,381 @@
+using System.Text.Json;
+using System.Globalization;
+using System.Text;
+
+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 const int MaxErrors = 20;
+ 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
+ /// 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();
+ 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;
+ }
+
+ /// 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();
+ 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;
+ }
+
+ 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.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))
+ {
+ errors.Add($"{path}: expected type '{DescribeType(type)}' but got {KindName(value.ValueKind)}");
+ return;
+ }
+
+ 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)
+ {
+ 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())
+ {
+ 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) && TryGetString(value, out var text) && text.EnumerateRunes().Count() > maximum)
+ {
+ errors.Add($"{path}: string exceeds maximum length {maximum}");
+ }
+ }
+
+ 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");
+ }
+ }
+
+ 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 (errors.Count >= MaxErrors || budget.Exhausted) return;
+ if (hasProperties && properties.TryGetProperty(property.Name, out var propertySchema))
+ 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 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})");
+ return;
+ }
+ if (++budget.Nodes > MaxNodes)
+ {
+ errors.Add("$: schema contains too many nodes");
+ budget.Exhausted = true;
+ return;
+ }
+
+ foreach (var keyword in schema.EnumerateObject())
+ {
+ if (errors.Count >= 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))
+ 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.GetArrayLength() > 0 && 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:
+ 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;
+ }
+ }
+
+ private static bool TryGetRepresentableNumber(string raw)
+ {
+ var exponentIndex = raw.IndexOfAny(['e', 'E']);
+ if (exponentIndex >= 0)
+ {
+ 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);
+ }
+
+ 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 (value[i] == '\0') return false;
+ 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()!),
+ 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,
+ "array" => value.ValueKind == JsonValueKind.Array,
+ "string" => value.ValueKind == JsonValueKind.String,
+ "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 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']);
+ var mantissa = exponentIndex < 0 ? rawNumber : rawNumber[..exponentIndex];
+ 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('-');
+ 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 = fractionalDigits - exponent;
+ if (remainingFractionalDigits <= 0) return true;
+ return remainingFractionalDigits <= digits.Length - significantDigits.Length;
+ }
+
+ private static bool JsonValueEquals(JsonElement left, JsonElement right)
+ {
+ return JsonElement.DeepEquals(left, right);
+ }
+
+ private static string KindName(JsonValueKind kind) => kind switch
+ {
+ 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 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
new file mode 100644
index 000000000..78ef54b23
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerRunAdmission.cs
@@ -0,0 +1,70 @@
+using System.Collections.Concurrent;
+using CodeSpace.Core.DependencyInjection;
+
+namespace CodeSpace.Core.Services.Workflows.ExternalTriggers;
+
+///
+/// Process-local budget; only durable new runs consume capacity.
+///
+public sealed class ExternalTriggerRunAdmission(TimeProvider? timeProvider = null) : ISingletonDependency, IDisposable
+{
+ public const int PermitsPerWindow = 60;
+ public static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
+
+ private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System;
+ private readonly ConcurrentDictionary _windows = new();
+
+ public Reservation? Reserve(Guid triggerId)
+ {
+ 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() => _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
+ {
+ 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
new file mode 100644
index 000000000..801326da8
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerSlug.cs
@@ -0,0 +1,6 @@
+namespace CodeSpace.Core.Services.Workflows.ExternalTriggers;
+
+public static class ExternalTriggerSlug
+{
+ public const string TypeKey = "trigger.external";
+}
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..6f26d14a4
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/ExternalTriggerStore.cs
@@ -0,0 +1,69 @@
+using CodeSpace.Core.Persistence.Db;
+using CodeSpace.Core.Persistence.Entities;
+using Microsoft.EntityFrameworkCore;
+
+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))
+ .ToArrayAsync(cancellationToken).ConfigureAwait(false);
+
+ public async Task FindAsync(Guid triggerId, Guid teamId, CancellationToken cancellationToken) =>
+ await db.WorkflowExternalTrigger.AsNoTracking()
+ .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);
+
+ ///
+ /// 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, Guid actorId, CancellationToken cancellationToken)
+ {
+ 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,
+ KeyDigest = key.Digest, KeyCiphertext = key.Ciphertext, CreatedBy = actorId, LastModifiedBy = actorId,
+ };
+ db.WorkflowExternalTrigger.Add(row);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return View(row);
+ }
+
+ public async Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken)
+ {
+ 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;
+ }
+
+ 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
new file mode 100644
index 000000000..ea4063cd6
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/ExternalTriggers/IExternalTriggerIngress.cs
@@ -0,0 +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(Guid triggerId, 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(Guid TriggerId, string? PresentedKey, string? IdempotencyKey, string RawBody, bool IdempotencyRejected, JsonElement? ParsedBody = null);
+
+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
new file mode 100644
index 000000000..eff194fce
--- /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(Guid triggerId, Guid teamId, CancellationToken cancellationToken);
+ Task> ListForWorkflowAsync(Guid workflowId, Guid teamId, CancellationToken cancellationToken);
+ Task EnsureForNodeAsync(Guid workflowId, Guid teamId, string nodeId, Guid actorId, CancellationToken cancellationToken);
+ Task RotateKeyAsync(Guid triggerId, Guid teamId, Guid actorId, CancellationToken cancellationToken);
+}
+
+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
new file mode 100644
index 000000000..a5680c112
--- /dev/null
+++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/TriggerExternalNode.cs
@@ -0,0 +1,40 @@
+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": {
+ "inputSchema": { "type": "object" }
+ },
+ "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/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs b/backend/src/CodeSpace.Core/Services/Workflows/WorkflowService.cs
index 18e3744ac..5a9c7275a 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)
@@ -164,6 +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 = ExternalTriggerDefinitionSync.Normalize(definition);
+ ExternalTriggerDefinitionSync.ValidateSchemas(definition);
+ definition = definition with { Inputs = ExternalTriggerInputProjection.Apply(definition.Inputs, definition) };
EnsureValidDefinition(definition);
await EnsureActivationRepositoriesHeldAsync(teamId, activations, cancellationToken).ConfigureAwait(false);
@@ -213,6 +219,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);
@@ -222,9 +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 = 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}.");
@@ -253,6 +265,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(
@@ -265,16 +279,27 @@ 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. 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;
+ t.KeyCiphertext = string.Empty;
+ }
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)
@@ -2211,10 +2236,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
new file mode 100644
index 000000000..9e50ad0ab
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Commands/Workflows/ReceiveExternalTriggerCommand.cs
@@ -0,0 +1,28 @@
+using CodeSpace.Messages.Dtos.Workflows;
+using CodeSpace.Messages.Mediation;
+using System.Text.Json;
+
+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 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; }
+
+ /// 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 JsonElement Body { 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..e155994e6
--- /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 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
new file mode 100644
index 000000000..020d3c2a4
--- /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 required Guid TriggerId { get; init; }
+}
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
new file mode 100644
index 000000000..c1778df9d
--- /dev/null
+++ b/backend/src/CodeSpace.Messages/Dtos/Workflows/ExternalTriggerDeliveryOutcome.cs
@@ -0,0 +1,31 @@
+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.
+/// A staged run is ; a deterministic admission
+/// refusal is or .
+///
+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 string? Code { get; init; }
+}
+
+public enum ExternalTriggerDeliveryKind
+{
+ Accepted,
+ Deduplicated,
+ NotFound,
+ Unauthorized,
+ Forbidden,
+ Unprocessable,
+ InvalidPayload,
+ RateLimited,
+}
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..f0ae28ffd
--- /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);
+
+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/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/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
new file mode 100644
index 000000000..10a866cc5
--- /dev/null
+++ b/backend/tests/CodeSpace.E2ETests/Webhooks/ExternalTriggerEndpointE2ETests.cs
@@ -0,0 +1,461 @@
+using System.Net;
+using System.Net.Http.Json;
+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;
+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;
+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, Guid TriggerId, 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", "workflowId" });
+ trigger.GetProperty("id").GetGuid().ShouldBe(seed.TriggerId);
+ trigger.GetProperty("workflowId").GetGuid().ShouldBe(seed.WorkflowId);
+ trigger.GetProperty("nodeId").GetString().ShouldBe("trigger");
+ }
+
+ [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_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.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.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);
+ using var newUrl = await PostAsync(Url(seed), BODY, apiKey: fresh);
+ newUrl.StatusCode.ShouldBe(HttpStatusCode.Accepted);
+ }
+
+ [Theory]
+ [InlineData("key", "GET")]
+ [InlineData("rotate-key", "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.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.TriggerId}/key");
+ anonymous.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task Independent_trigger_identities_have_independent_endpoints()
+ {
+ var first = await SeedAsync();
+ var other = await SeedAsync();
+ 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);
+ otherCall.StatusCode.ShouldBe(HttpStatusCode.Accepted);
+ }
+
+ [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("empty-bearer-fallback")]
+ [InlineData("wrong-scheme-fallback")]
+ 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;
+ 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();
+ var runs = await RunsAsync(seed);
+ 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()
+ {
+ 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', 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.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();
+ payload.GetProperty("errors").EnumerateArray().Select(e => e.GetString()).ShouldBe(expected.Errors);
+ (await response.Content.ReadAsStringAsync()).ShouldContain("$.service");
+ 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);
+ }
+
+ [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);
+ }
+
+ [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)]
+ 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_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.Forbidden);
+ var payload = await response.Content.ReadFromJsonAsync();
+ 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();
+ }
+
+ [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("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();
+ }
+
+ [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.TriggerId}/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 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, 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());
+
+ 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.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
new file mode 100644
index 000000000..73df59612
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressContractTests.cs
@@ -0,0 +1,137 @@
+using System.Text.Json;
+using System.Reflection;
+using CodeSpace.Api.Controllers;
+using CodeSpace.Api.Extensions;
+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.Mvc;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http.Metadata;
+using Microsoft.AspNetCore.RateLimiting;
+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")]
+[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(ExternalTriggersController);
+ controller.GetCustomAttribute().ShouldNotBeNull();
+ controller.GetCustomAttribute().ShouldNotBeNull().Template.ShouldBe("api/ext");
+ var method = controller.GetMethod("Run").ShouldNotBeNull();
+ 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 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();
+ 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));
+ }
+
+ [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
new file mode 100644
index 000000000..ebafc4b27
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerIngressFlowTests.cs
@@ -0,0 +1,507 @@
+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;
+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 CodeSpace.Messages.Failures;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+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, 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()
+ {
+ 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_trigger_is_unauthorized()
+ {
+ var seed = await SeedAsync();
+ (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]
+ [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.Id == seed.TriggerId);
+ trigger.NodeId = "removed-node";
+ await db.SaveChangesAsync();
+ }
+
+ var outcome = await ReceiveAsync(seed, presentedKey: correctKey ? seed.Key : "wrong");
+ 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_a_server_error_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 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();
+ }
+
+ 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")]
+ [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.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.Unauthorized);
+ (await ReceiveAsync(seed)).Kind.ShouldBe(state == "deleted-trigger" ? ExternalTriggerOutcomeKind.Unauthorized : 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 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.TriggerId, 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()
+ {
+ 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 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()
+ {
+ 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 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.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())
+ {
+ 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.Deduplicated);
+ (await RunsAsync(seed)).ShouldHaveSingleItem().RunRequest.ActivationId.ShouldBe(seed.ActivationId);
+ }
+
+ [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", 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())
+ {
+ var db = scope.Resolve();
+ if (completion)
+ {
+ 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 });
+ }
+ 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(expectedKind);
+ 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.ShouldNotBeNull();
+ audit.Error!.ShouldContain(reason);
+ audit.IdempotencyKey.ShouldStartWith($"refused:");
+ audit.IdempotencyKey.ShouldContain($":{reason}:");
+ }
+
+ [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.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();
+ 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(), 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 = seed.TriggerId, TeamId = teamId, WorkflowId = seed.WorkflowId, NodeId = "start", 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.TriggerId, 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.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs
new file mode 100644
index 000000000..b25d14ca7
--- /dev/null
+++ b/backend/tests/CodeSpace.IntegrationTests/Workflows/ExternalTriggerManagementFlowTests.cs
@@ -0,0 +1,511 @@
+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 CodeSpace.Messages.Queries.Workflows;
+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, Guid TriggerId, string Slug, string Key);
+
+ [Fact]
+ public async Task Metadata_returns_the_stable_trigger_identity_without_a_slug()
+ {
+ var seed = await SeedAsync();
+ 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");
+ 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]
+ 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", 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)]
+ 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")]
+ [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);
+ (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 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);
+
+ await UpdateAsync(seed, Definition("告警"));
+
+ var after = await RowAsync(seed);
+ after.Id.ShouldBe(before.Id);
+ 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);
+
+ }
+
+ [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 { TriggerId = seed.TriggerId })).ShouldBe(seed.Key);
+ }
+
+ [Theory]
+ [InlineData(TeamRole.Viewer, "reveal")]
+ [InlineData(TeamRole.Viewer, "rotate")]
+ [InlineData(TeamRole.Member, "reveal")]
+ [InlineData(TeamRole.Member, "rotate")]
+ [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.TriggerId));
+ 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 { 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 { TriggerId = seed.TriggerId })).ShouldBe(fresh);
+ }
+
+ [Fact]
+ public async Task Rotation_waits_for_workflow_retirement_and_cannot_restore_the_retired_key()
+ {
+ var seed = await SeedAsync();
+ 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();
+ }
+
+ private async Task WaitForLockAsync(int processId)
+ {
+ 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")]
+ 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.TriggerId));
+ (await ReceiveAsync(first)).Kind.ShouldBe(ExternalTriggerOutcomeKind.Accepted);
+ }
+
+ [Theory]
+ [InlineData("reveal")]
+ [InlineData("rotate")]
+ 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.TriggerId));
+ error.ShouldBeOfType();
+ FailureClassifier.Classify(error).Kind.ShouldBe(FailureKind.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.Id == seed.TriggerId && t.DeletedDate == null)).ShouldBe(0);
+ }
+
+ [Theory]
+ [InlineData("!!!")]
+ [InlineData("告警")]
+ [InlineData("")]
+ 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);
+ await CreateAsync(scope, Definition(desired));
+ using var verify = fixture.BeginScope();
+ (await verify.Resolve().Workflow.CountAsync(w => w.TeamId == teamId)).ShouldBe(1);
+ (await verify.Resolve().WorkflowExternalTrigger.CountAsync(w => w.TeamId == teamId)).ShouldBe(1);
+ }
+
+ [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);
+ }
+
+ [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 (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 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 workflowId = await CreateAsync(scope, Definition(seed.Slug));
+ using var verify = fixture.BeginScope();
+ var row = await verify.Resolve