Skip to content

Commit 5448f72

Browse files
Merge pull request #25 from CyberDrain/dev
feat(endpoints): add native C# endpoint host
2 parents bcc929b + 3378e67 commit 5448f72

31 files changed

Lines changed: 2977 additions & 32 deletions

Craft.csproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,13 @@
7979
<EmbeddedResource Include="Services\Setup\*.html" LogicalName="Craft.Setup.%(Filename)%(Extension)" />
8080
</ItemGroup>
8181

82+
<!--
83+
So the test project can reach helpers that are correctly internal to the host — the endpoint
84+
dispatch/discovery policies (ExecuteAsync, SelectHandler, BuildScheduledTasks) are authorization
85+
plumbing worth testing directly but have no business being public API.
86+
-->
87+
<ItemGroup>
88+
<InternalsVisibleTo Include="Craft.Tests" />
89+
</ItemGroup>
90+
8291
</Project>

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Craft/
1212
│ ├── Program.cs # Host startup, middleware, endpoint mapping
1313
│ ├── Bridges/ # PowerShell-facing API surface → namespace Craft.Services (PINNED)
1414
│ ├── Configuration/ # Settings types, one per file → Craft.Configuration
15+
│ ├── Endpoints/ # Native C# endpoint/task contracts → Craft.Endpoints
1516
│ ├── PowerShellHost/ # Runspace workers, pool, script repo → Craft.PowerShellHost
1617
│ ├── Orchestration/ # Orchestrator, scheduler, jobs → Craft.Orchestration
1718
│ ├── Storage/ # Azure Table stores, health → Craft.Storage

Services/Configuration/CraftSettings.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ public class CraftSettings
6767
/// <summary>Bootstrap setup — built-in first-run wizard for EasyAuth + app registration.</summary>
6868
public SetupSettings Setup { get; set; } = new();
6969

70+
/// <summary>OAuth protected resource metadata (RFC 9728) served for MCP/OAuth discovery.</summary>
71+
public PrmSettings Prm { get; set; } = new();
72+
7073
/// <summary>Historical stats collection — rolling time-series of worker/job metrics.</summary>
7174
public StatsHistorySettings StatsHistory { get; set; } = new();
7275

@@ -89,6 +92,12 @@ public class CraftSettings
8992
/// <summary>Azure Storage connection policy — see <see cref="StorageSettings"/>. Governs the dev-emulator fallback.</summary>
9093
public StorageSettings Storage { get; set; } = new();
9194

95+
/// <summary>
96+
/// Native C# endpoints hosted alongside the PowerShell ones. Off unless an application names the
97+
/// assemblies to scan. See <see cref="EndpointSettings"/>.
98+
/// </summary>
99+
public EndpointSettings Endpoints { get; set; } = new();
100+
92101
/// <summary>Kestrel request limits (body size, connection cap). See <see cref="KestrelLimitsSettings"/>.</summary>
93102
public KestrelLimitsSettings Limits { get; set; } = new();
94103

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
namespace Craft.Configuration;
2+
3+
/// <summary>
4+
/// Native C# endpoints, hosted alongside the PowerShell ones.
5+
///
6+
/// <para>
7+
/// Off by default. An application opts in by naming the assemblies its endpoints live in; those load
8+
/// at startup and each <c>[CraftEndpoint]</c> type is mapped at its literal route. Because a literal
9+
/// route outranks the PowerShell catch-all, endpoints migrate one at a time with the PowerShell
10+
/// function left in place as the rollback.
11+
/// </para>
12+
/// </summary>
13+
public class EndpointSettings
14+
{
15+
/// <summary>
16+
/// Master switch. Default false, so a deployment that names no assemblies pays nothing —
17+
/// no assembly loading, no reflection, no route mapping.
18+
/// </summary>
19+
public bool Enabled { get; set; }
20+
21+
/// <summary>
22+
/// Assemblies to scan, absolute or relative to the API base path (e.g.
23+
/// <c>bin/GeoIpDb.Endpoints.dll</c>). Loaded into the default load context and never unloaded —
24+
/// see <c>NativeEndpointRegistry</c> for why a collectible context would be a trap.
25+
/// </summary>
26+
public List<string> Assemblies { get; set; } = [];
27+
28+
/// <summary>
29+
/// What to do when a native endpoint claims a route a PowerShell function already has.
30+
///
31+
/// <list type="bullet">
32+
/// <item><description><c>PreferNative</c> (default) — the native endpoint wins and the
33+
/// shadowing is logged. This is the mode that makes migration work: flip one endpoint, keep the
34+
/// PowerShell one loaded as the rollback.</description></item>
35+
/// <item><description><c>PreferPowerShell</c> — instant rollback with no rebuild.</description></item>
36+
/// <item><description><c>Fail</c> — refuse to start, naming every collision. The right setting
37+
/// for CI, where a route shadow should be caught before it ships.</description></item>
38+
/// </list>
39+
///
40+
/// <c>Fail</c> is arguably the safer runtime default and is deliberately not the one chosen: on a
41+
/// config-driven runtime it turns an accidental shadow into a failed rolling deploy. Set it in CI
42+
/// and leave <c>PreferNative</c> in production.
43+
/// </summary>
44+
public string OnCollision { get; set; } = "PreferNative";
45+
46+
/// <summary>
47+
/// Refuse to start when endpoints declare Central dispatch and no <c>ICraftEndpointHandler</c>
48+
/// was found in the scanned assemblies. Default false: a handler-less application simply
49+
/// dispatches every endpoint directly, which is exactly the pre-handler behaviour.
50+
/// </summary>
51+
/// <remarks>
52+
/// Set it true — in CI at minimum — for applications whose authorization lives in the central
53+
/// handler. For them a missing handler does not mean "less middleware"; it means every Central
54+
/// endpoint is reachable with no auth check at all, and that should fail the deploy, not ship.
55+
/// The same reasoning as <see cref="OnCollision"/>=Fail, and the same split applies: hard-fail
56+
/// is right where a human sees the failure before traffic does.
57+
/// </remarks>
58+
public bool RequireHandler { get; set; }
59+
60+
/// <summary>
61+
/// Blanket in-flight limit for native endpoints that declare none. 0 (default) means unbounded.
62+
/// </summary>
63+
/// <remarks>
64+
/// <para>
65+
/// With <c>Worker:HttpPoolSize=0</c> there is no runspace pool, and the pool was what implicitly
66+
/// capped concurrent work — a request could only run if a worker was free. Native endpoints are
67+
/// async end to end and hold no thread while waiting on an upstream, so nothing stops the process
68+
/// accepting far more concurrent requests than it can finish. Memory is what gives out first: each
69+
/// in-flight request holds its request and response buffers.
70+
/// </para>
71+
/// <para>
72+
/// This is a concurrency limit, not a rate limit, and the two solve different problems.
73+
/// <c>App:RateLimit:*</c> stops one caller monopolising the service and is partitioned per client;
74+
/// this bounds total simultaneous work regardless of who asked. A service behind a trusted
75+
/// single caller needs this one and not the other.
76+
/// </para>
77+
/// </remarks>
78+
public int MaxConcurrency { get; set; }
79+
80+
/// <summary>
81+
/// Per-route in-flight limits, keyed by route (e.g. <c>"GeoDBDownload": 4</c>). Overrides both the
82+
/// endpoint's <c>[CraftEndpoint(MaxConcurrency = n)]</c> and <see cref="MaxConcurrency"/>. Set 0 to
83+
/// explicitly remove a limit the endpoint declared.
84+
/// </summary>
85+
/// <remarks>
86+
/// Resolution order is: this, then the attribute, then <see cref="MaxConcurrency"/>. The attribute
87+
/// outranks the blanket default deliberately — an endpoint that declares a limit is asserting
88+
/// something about itself that an operator setting a global default has no way to know, and
89+
/// silently widening it would turn a safety limit into a footgun. Overriding it still works; it
90+
/// just has to be said explicitly, by name.
91+
/// </remarks>
92+
public Dictionary<string, int> Concurrency { get; set; } = new(StringComparer.OrdinalIgnoreCase);
93+
94+
/// <summary>
95+
/// How long a request waits for a concurrency slot before being shed with 503. Default 0, meaning
96+
/// each endpoint's own <c>QueueTimeoutSeconds</c> applies.
97+
/// </summary>
98+
public int QueueTimeoutSeconds { get; set; }
99+
100+
/// <summary>
101+
/// Resolves the in-flight limit for a route. See <see cref="Concurrency"/> for why the attribute
102+
/// outranks the blanket default.
103+
/// </summary>
104+
public int ResolveConcurrency(string route, int declared)
105+
{
106+
if (Concurrency.TryGetValue(route, out var configured)) return Math.Max(0, configured);
107+
return declared > 0 ? declared : Math.Max(0, MaxConcurrency);
108+
}
109+
110+
/// <summary>Resolves the queue timeout for a route, preferring the global override when set.</summary>
111+
public int ResolveQueueTimeout(int declared) =>
112+
QueueTimeoutSeconds > 0 ? QueueTimeoutSeconds : declared;
113+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace Craft.Configuration;
2+
3+
/// <summary>
4+
/// OAuth 2.0 Protected Resource Metadata (RFC 9728) served by Craft itself at
5+
/// <c>/.well-known/oauth-protected-resource</c>, so OAuth clients (MCP clients in particular) can
6+
/// discover how to obtain a token for the hosted API.
7+
///
8+
/// <para>
9+
/// The document is NOT assembled from configuration — the hosted application writes the complete
10+
/// JSON into one app setting (<see cref="SettingName"/>) and Craft serves it verbatim, so the
11+
/// application controls every field. A literal <c>{origin}</c> anywhere in the JSON is replaced
12+
/// per-request with <c>https://{host}</c>: RFC 9728 requires <c>resource</c> to equal the URL the
13+
/// client is actually connecting to, which only the request knows once custom domains are in play.
14+
/// </para>
15+
///
16+
/// <para>
17+
/// Why not the platform's own PRM (preview): it derives <c>authorization_servers</c> from the
18+
/// EasyAuth provider's <c>openIdIssuer</c> with no independent override — a multi-tenant SSO setup
19+
/// therefore advertises the <c>/common</c> endpoint, which a single-tenant resource app
20+
/// registration cannot authorize on (AADSTS50194). Craft only serves metadata: token issuance and
21+
/// validation remain entirely with Entra and EasyAuth. The platform's PRM must stay dormant for
22+
/// Craft's to be reachable — do NOT set WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES (it activates the
23+
/// platform document, which intercepts the well-known path before the container sees the request).
24+
/// The well-known path is appended to EasyAuth's excludedPaths automatically while this feature is
25+
/// enabled.
26+
/// </para>
27+
/// </summary>
28+
public class PrmSettings
29+
{
30+
/// <summary>
31+
/// The well-known path (RFC 9728 §3). Suffixed variants
32+
/// (<c>/.well-known/oauth-protected-resource/api/Foo</c>) identify a specific resource path.
33+
/// </summary>
34+
public const string WellKnownPath = "/.well-known/oauth-protected-resource";
35+
36+
/// <summary>
37+
/// Master switch. When enabled and the app setting named by <see cref="SettingName"/> holds
38+
/// valid JSON, Craft serves it and the setup reconcile keeps the well-known path in EasyAuth's
39+
/// excludedPaths. When the setting is absent, nothing is served — its presence is the
40+
/// per-instance "an OAuth resource exists here" signal, written and cleared by the hosted app.
41+
/// </summary>
42+
public bool Enabled { get; set; }
43+
44+
/// <summary>
45+
/// Name of the app setting (environment variable) holding the complete PRM JSON document.
46+
/// </summary>
47+
public string SettingName { get; set; } = "CRAFT_PRM";
48+
}

Services/Configuration/WorkerSettings.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@ namespace Craft.Configuration;
55
/// </summary>
66
public class WorkerSettings
77
{
8-
/// <summary>Number of workers reserved for HTTP request handling.</summary>
8+
/// <summary>
9+
/// Number of workers reserved for HTTP request handling.
10+
///
11+
/// <para>
12+
/// <b>0 means no PowerShell HTTP hosting at all</b> — for an application whose HTTP endpoints are
13+
/// all native C#. The pool is then never built, so the node pays neither runspace construction at
14+
/// startup nor their resident memory, and readiness is signalled immediately so the startup gate
15+
/// does not wait for a pool that will never exist.
16+
/// </para>
17+
/// </summary>
918
public int HttpPoolSize { get; set; } = 2;
1019

1120
/// <summary>Number of workers reserved for background jobs (scheduler, orchestrator, queue).</summary>
@@ -26,6 +35,32 @@ public class WorkerSettings
2635
/// </summary>
2736
public bool IgnoreSkuProfiles { get; set; }
2837

38+
/// <summary>
39+
/// Minimum .NET thread-pool worker/completion threads. <b>0 (default) = derive from the pool
40+
/// sizes</b>, which is almost always what you want; set a number only to pin it.
41+
///
42+
/// <para>
43+
/// This matters far more than it looks. PowerShell has no async story, so every outbound call a
44+
/// script makes — <c>Invoke-RestMethod</c>, or any <c>.GetAwaiter().GetResult()</c> against an
45+
/// HttpClient — blocks a thread for the whole round trip. A pool of N workers can therefore have
46+
/// N threads parked at once. Above the thread-pool minimum the CLR injects new threads at roughly
47+
/// <b>one per second</b>, so a worker pool larger than the minimum cannot actually reach its own
48+
/// concurrency until that ramp finishes.
49+
/// </para>
50+
///
51+
/// <para>
52+
/// Measured on a 1-core container with the old fixed floor of 32: a pool of 48 served 5.5 req/s
53+
/// with a 17.5s p95 over a 15-second window, and 120 req/s with a 0.8s p95 over 60 seconds —
54+
/// same configuration, the difference being only whether the injection ramp fell inside the
55+
/// measurement. In production that ramp is a real cold-start cost on every restart.
56+
/// </para>
57+
///
58+
/// Env override: <c>CRAFT_MIN_THREADS</c>. Note that the .NET
59+
/// <c>DOTNET_ThreadPool_MinThreads</c> variable does NOT work here — the host calls
60+
/// <c>ThreadPool.SetMinThreads</c> at startup, which overwrites it.
61+
/// </summary>
62+
public int MinThreads { get; set; }
63+
2964
/// <summary>
3065
/// Maximum execution time in seconds for a single HTTP request handler.
3166
/// When exceeded, the PowerShell pipeline is stopped and the worker is reclaimed.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
2+
namespace Craft.Endpoints;
3+
4+
/// <summary>
5+
/// Whether a native endpoint is dispatched through the application's central handler
6+
/// (<see cref="ICraftEndpointHandler"/>) or invoked directly.
7+
/// </summary>
8+
public enum EndpointDispatch
9+
{
10+
/// <summary>
11+
/// Routed through the central handler when the application registered one. The default, and the
12+
/// safe direction for it: an endpoint that forgets to declare a dispatch mode gets the
13+
/// application's authorization, not an accidentally-public route.
14+
/// </summary>
15+
Central,
16+
17+
/// <summary>
18+
/// Bypasses the central handler. For endpoints that authenticate differently (a webhook
19+
/// verifying a signature) or deliberately serve anonymous callers (a public redirect). This is a
20+
/// property of the endpoint's security design, which is why it is declared here in code rather
21+
/// than in configuration — flipping a route to Direct should be a code review, not a YAML edit.
22+
/// </summary>
23+
Direct,
24+
}
25+
26+
/// <summary>
27+
/// Marks a class as a native endpoint and declares its route and metadata. The C# counterpart of the
28+
/// PowerShell convention where <c>Invoke-GetIPInfo</c> becomes <c>/API/GetIPInfo</c> and the
29+
/// <c>.ROLE</c> / <c>.FUNCTIONALITY</c> doc tags feed the permission map.
30+
/// </summary>
31+
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
32+
public sealed class CraftEndpointAttribute : Attribute
33+
{
34+
public CraftEndpointAttribute(string route)
35+
{
36+
ArgumentException.ThrowIfNullOrWhiteSpace(route);
37+
Route = route.Trim('/');
38+
}
39+
40+
/// <summary>
41+
/// Route segment beneath <c>/API/</c>. Declared explicitly rather than derived from the type name
42+
/// so that migrating an endpoint from PowerShell cannot change its URL — the existing callers are
43+
/// the reason this whole exercise has to be invisible from outside.
44+
/// </summary>
45+
public string Route { get; }
46+
47+
/// <summary>HTTP methods this endpoint answers. Defaults to the same set the PS dispatcher accepts.</summary>
48+
public string[] Methods { get; init; } = ["GET", "POST", "PUT", "DELETE", "PATCH"];
49+
50+
/// <summary>Equivalent of the PowerShell <c>.ROLE</c> doc tag; feeds function-permissions.json.</summary>
51+
public string? Role { get; init; }
52+
53+
/// <summary>Equivalent of the PowerShell <c>.FUNCTIONALITY</c> doc tag.</summary>
54+
public string? Functionality { get; init; }
55+
56+
/// <summary>
57+
/// Ceiling on concurrent executions of THIS endpoint. 0 (default) means unbounded, which is
58+
/// usually right for an async endpoint and is much of the point of being native.
59+
///
60+
/// <para>
61+
/// Set it when unbounded concurrency would hurt something downstream. The worker pool is what
62+
/// bounds PowerShell endpoints today — it sheds to a 503 once saturated — and a native endpoint
63+
/// has no equivalent, so it is limited only by Kestrel's connection cap. Two things that makes
64+
/// worse: fanning out to a rate-limited upstream (the failure moves off the host and onto the
65+
/// bill), and endpoints whose memory scales with concurrency.
66+
/// </para>
67+
/// </summary>
68+
public int MaxConcurrency { get; init; }
69+
70+
/// <summary>
71+
/// How long a request waits for a slot when <see cref="MaxConcurrency"/> is reached, before being
72+
/// shed with a 503. Matches the PowerShell pool's 30s checkout timeout so the client-visible
73+
/// behaviour does not change as endpoints migrate.
74+
/// </summary>
75+
public int QueueTimeoutSeconds { get; init; } = 30;
76+
77+
/// <summary>
78+
/// Singleton by default: it matches the process-wide state a PowerShell module holds, costs no
79+
/// per-request allocation, and lets an endpoint keep a pooled HttpClient in a field.
80+
/// </summary>
81+
public ServiceLifetime Lifetime { get; init; } = ServiceLifetime.Singleton;
82+
83+
/// <summary>
84+
/// Whether requests to this endpoint go through the application's central handler
85+
/// (<see cref="ICraftEndpointHandler"/>). Defaults to <see cref="EndpointDispatch.Central"/>;
86+
/// with no handler registered the two modes behave identically, so existing applications are
87+
/// unaffected until they ship one.
88+
/// </summary>
89+
public EndpointDispatch Dispatch { get; init; } = EndpointDispatch.Central;
90+
}

Services/Endpoints/CraftJson.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Text.Json;
2+
3+
namespace Craft.Endpoints;
4+
5+
/// <summary>
6+
/// The JSON conventions native endpoints read and write by default.
7+
///
8+
/// <para>
9+
/// <see cref="JsonSerializerDefaults.Web"/>, which means camelCase property names and case-insensitive
10+
/// reads — the same options ASP.NET Core's own minimal APIs and MVC use. Using
11+
/// <c>System.Text.Json</c>'s bare defaults instead would make <c>{"hostname":"x"}</c> fail to bind to a
12+
/// <c>Hostname</c> property and would emit <c>{"Hostname":…}</c> back, so every application would
13+
/// discover the same trap and paste the same options object into every endpoint.
14+
/// </para>
15+
/// </summary>
16+
internal static class CraftJson
17+
{
18+
/// <summary>Shared instance — <see cref="JsonSerializerOptions"/> caches its metadata, so reusing one is what keeps serialization fast.</summary>
19+
internal static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
20+
}

0 commit comments

Comments
 (0)