Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d42b155
feat: external trigger slug rules and payload contract validator
fengzai6 Oct 20, 2026
6f1352a
fix: validate integer payloads without floating point rounding
fengzai6 Oct 20, 2026
b21b2ab
feat: workflow_external_trigger storage and external trigger key sealing
fengzai6 Oct 20, 2026
14bea2c
feat: trigger.external node runtime
fengzai6 Oct 20, 2026
417ba38
feat: external trigger ingress with key auth, contract check and call…
fengzai6 Oct 20, 2026
3407cbe
fix: bind external trigger ingress to the current version node
fengzai6 Oct 20, 2026
7db74e2
feat: anonymous POST /api/ext/{slug}/run ingress controller
fengzai6 Oct 20, 2026
18065f7
test: cover authenticated invalid external trigger bodies
fengzai6 Oct 20, 2026
522f6d9
feat: external trigger key reveal, rotate and rename plus save-path sync
fengzai6 Oct 20, 2026
837e00a
fix: clarify external trigger slug errors and cover refused draft saves
fengzai6 Oct 20, 2026
439d804
fix: align external trigger slug guidance with derivation rules
fengzai6 Oct 20, 2026
0897a9a
feat: project the external trigger input contract onto workflow inputs
fengzai6 Oct 20, 2026
0e540fa
Add team-scoped live external trigger metadata endpoint
fengzai6 Oct 20, 2026
27f84dd
feat: external trigger node inspector with endpoint, key reveal, rota…
fengzai6 Oct 20, 2026
7768ca7
fix: preserve non-object external trigger field schemas
fengzai6 Oct 20, 2026
298cd52
Route the external trigger ingress through the mediator
fengzai6 Oct 20, 2026
8060dcb
Retire external trigger rows when a workflow is deleted
fengzai6 Oct 20, 2026
7e7e565
Explain an unusable draft slug in the rename confirmation
fengzai6 Oct 20, 2026
238008c
Fix external trigger key handling on retirement
fengzai6 Oct 21, 2026
48f674c
Support union types in external trigger schemas
fengzai6 Oct 21, 2026
41b1c3f
Stabilize model call capture polling test
fengzai6 Oct 21, 2026
f60497b
Harden external trigger ingress
fengzai6 Oct 23, 2026
0003bcf
Classify external trigger admission refusals
fengzai6 Oct 23, 2026
03c9cba
Allow anonymous external trigger verification
fengzai6 Oct 23, 2026
5ed1097
Harden external trigger ingress contracts
fengzai6 Oct 26, 2026
21c2f1b
Remove cross-stack schema test fixture dependency
fengzai6 Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Anonymous ingress authenticates before reading the bounded body or opening the receive transaction.
/// </summary>
[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<IActionResult> 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." });
}
}
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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 });
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using CodeSpace.Messages.Failures;
using System.Globalization;

namespace CodeSpace.Api.Extensions;

Expand Down Expand Up @@ -37,7 +39,7 @@ public static class WebhookIngressRateLimitExtension
public static readonly TimeSpan Window = TimeSpan.FromMinutes(1);

/// <summary>The route values that name what a request is addressed to, in the order the ingress routes declare them.</summary>
private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token" };
private static readonly string[] TargetRouteValues = { "webhookId", "connectionWebhookId", "token", "triggerId" };

/// <summary>The one partition every non-ingress request shares in the global limiter — no limit. Never an address.</summary>
private const string NotIngress = "not-ingress";
Expand All @@ -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<HttpContext, string>(SourcePartition);
options.AddPolicy(PolicyName, context => FixedWindow(PartitionKey(context), PermitsPerWindow));
});
Expand All @@ -70,6 +78,7 @@ private static RateLimitPartition<string> 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)}";
}
Expand Down
41 changes: 41 additions & 0 deletions backend/src/CodeSpace.Api/Http/BoundedJsonBodyReader.cs
Original file line number Diff line number Diff line change
@@ -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<JsonDocument> 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<int> ReadAsync(Memory<byte> 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<int> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<RevealExternalTriggerKeyCommandHandler> logger)
: IRequestHandler<RevealExternalTriggerKeyCommand, string>
{
public async Task<string> 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<RotateExternalTriggerKeyCommand, string>
{
public async Task<string> 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<ReceiveExternalTriggerCommand, ExternalTriggerDeliveryOutcome>
{
public async Task<ExternalTriggerDeliveryOutcome> 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<VerifyExternalTriggerQuery, ExternalTriggerVerificationOutcome>
{
public async Task<ExternalTriggerVerificationOutcome> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ListWorkflowExternalTriggersQuery, WorkflowExternalTriggersResult?>
{
public async Task<WorkflowExternalTriggersResult?> 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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using CodeSpace.Core.DependencyInjection;

namespace CodeSpace.Core.Middlewares.Transactional;

public sealed class TransactionCompletionCallbacks : IScopedDependency, IDisposable
{
private readonly List<Func<CancellationToken, Task>> _committed = [];
private readonly List<Action> _rolledBack = [];

public void RegisterAfterCommit(Func<CancellationToken, Task> 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();
}
Loading
Loading