Craft (CyberDrain Runtime for Apps, Functions, Tasks) is configured through ASP.NET Core's standard configuration system. All application-specific settings live under the "App" section.
Craft ships no appsettings.json. Every setting's default is the C# property initialiser in
Services/CraftSettings.cs — that file is the single source of truth, and a
deployment that sets nothing at all gets exactly those values.
appsettings.example.jsonc is an annotated reference listing every key
alongside its default. It is documentation only: the .jsonc extension keeps it out of the
appsettings*.json content glob in Craft.csproj, so it is never copied to the published output or the
container image. It carries comments, which strict JSON does not allow — hence the extension.
Copy out of it only the keys you are actually changing. Restating a default in your own config creates a value that silently goes stale the day the default moves.
Settings are merged in priority order (highest wins):
- Environment variables —
App__Worker__BgPoolSize=8 Properties/launchSettings.json— profile env vars injected bydotnet run/ Visual Studio (local dev only, ignored in Docker/production)appsettings.{Environment}.json— e.g.appsettings.Development.json(loaded whenASPNETCORE_ENVIRONMENT=Development)appsettings.json— supplied by your app, if you supply one (always loaded, all environments)- C# property defaults in
Services/CraftSettings.cs— the floor; always present
Both appsettings files are optional. When both are present the environment-specific file overlays onto the
base — it doesn't replace it — and values in the environment file win for the same key. Note that both are
gitignored in this repo, since a local appsettings.json is the file most likely to hold a storage
connection string.
Environment variables use __ (double underscore) as the section separator:
App__Worker__BgPoolSize=8
App__Auth__CookieName=my-session
App__Scheduler__CheckIntervalSeconds=60
In Docker Compose:
environment:
- App__Worker__BgPoolSize=8
- App__Worker__HttpPoolSize=3
- CRAFT_LOG_LEVEL=DebugCraft ships as one image that can run in split roles selected at runtime, so the cheap-to-scale static frontend and the compute-heavy PowerShell backend can be deployed and scaled independently.
Three independent capabilities:
| Capability | Serves | Env flag | Config |
|---|---|---|---|
| Frontend | static web content from Frontend/ |
CRAFT_SERVE_FRONTEND |
App:Roles:Frontend |
| Http | /api + auth (/login, /.auth/*, /api/me) via the HTTP pool |
CRAFT_SERVE_API |
App:Roles:Http |
| Background | scheduler / orchestrator / job-manager / stats via the BG pool | CRAFT_RUN_BACKGROUND |
App:Roles:Background |
Resolution (highest wins):
- If any role is explicitly set (env
CRAFT_SERVE_*/CRAFT_RUN_*wins overApp:Roles:*) → the host uses exactly those; unset roles default off. (So you declare roles by enabling what you want.) - Else (nothing set) → all three on — the default monolith.
If all three resolve off, the host fails fast (EX_CONFIG). The resolved set is logged at startup:
[System] Roles: Frontend=on Http=off Background=off | ResponseCache=off Compression=on.
Presets (combinations that fall out of the flags):
| Preset | Frontend | Http | Background | Use |
|---|---|---|---|---|
frontend |
✓ | – | – | Pure static host (CDN origin), no PowerShell |
http |
– | ✓ | – | API-only node (does not process orchestrations — see caveat) |
background |
– | – | ✓ | Worker node — scheduler + orchestrator processing |
backend |
– | ✓ | ✓ | Self-contained API + workers, no frontend |
frontend+http |
✓ | ✓ | – | App node without background workers |
combined (default) |
✓ | ✓ | ✓ | The monolith |
A node without the Http role maps none of the API/auth handlers, so /api, /.auth, /login fall
through to static serving first (a Frontend node can expose e.g. /api/me from its own static dir) and
finally to MapFallback (which 404s unmatched /api//.auth). A node without the Frontend role serves
no static content and 404s SPA routes.
A role-agnostic health endpoint is available in every mode (200 while the process is up; body reports
per-role readiness) — point Azure/K8s liveness at it. It defaults to /healthz and can be relocated or
disabled: App:Health:Path / CRAFT_HEALTH_PATH (e.g. /status) and App:Health:Enabled /
CRAFT_HEALTH_ENABLED=false.
⚠️ Orchestration caveat. Triggering an orchestration and processing it must happen in the same process — the trigger is an in-process queue, not a durable cross-process one. A purehttpnode that triggers orchestrations will not run them. Any node that triggers orchestrations from HTTP must also carry the Background role: usebackendorcombined. A purebackgroundnode self-triggers via its own scheduler and processes in-process, which is fine.
"Roles": {
"Frontend": true, // null/unset = not explicitly set
"Http": true,
"Background": false
}Docker Compose (env flags, same image):
# frontend node (static origin behind a CDN)
environment: [ CRAFT_SERVE_FRONTEND=true ]
# api node (API only — no orchestration processing)
environment: [ CRAFT_SERVE_API=true, WEBSITE_AUTH_CLIENT_ID=..., AUTH_SECRET=... ]
# worker node (scheduler + orchestrator processing)
environment: [ CRAFT_RUN_BACKGROUND=true, AzureWebJobsStorage=... ]
# backend node (API + workers, no frontend) — self-contained backend
environment: [ CRAFT_SERVE_API=true, CRAFT_RUN_BACKGROUND=true, WEBSITE_AUTH_CLIENT_ID=..., AUTH_SECRET=..., AzureWebJobsStorage=... ]
# monolith (unchanged default) — no role flagsResponse cache & roles: the API response cache (see Cache) defaults on only when a node
serves both a browser UI and its API (combined / frontend+http) and off otherwise. Override per node
with App:Cache:Enabled (bool) or CRAFT_RESPONSE_CACHE=true/false.
Controls the PowerShell runspace pools that execute all scripts.
"Worker": {
// Number of dedicated HTTP request workers. Each handles one request at a time.
// Increase if you see HTTP requests queuing during concurrent load.
// 0 = build no HTTP runspaces — for apps whose routes are all native C# endpoints.
"HttpPoolSize": 2,
// Number of background workers for scheduler, orchestrator, and queue tasks.
// Higher = more parallel orchestrator tasks, but more memory.
// 0 = build no BG runspaces — for apps whose scheduled work is all native C# tasks (the
// scheduler and JobManager still run; native tasks execute on the .NET thread pool).
"BgPoolSize": 4,
// Run each worker's PowerShell pipeline on one reused thread instead of a new thread per invocation.
// Default true — the biggest single per-request dispatch win (~50% of the PS-invoke cost, +68% throughput
// on dispatch-bound load; see docs/dispatch-analysis.md), and matches the Azure Functions PS worker's
// persistent runspace. Safe: each worker owns one runspace and serves one request at a time. Set false
// only to A/B or if a module misbehaves on a long-lived pipeline thread.
"ReuseRunspaceThread": true,
// Maximum execution time (seconds) for HTTP request handlers.
// When exceeded, the PowerShell pipeline is stopped and the worker is reclaimed.
// 0 = no timeout (default). Recommended: 120-300 for HTTP endpoints.
"HttpTimeoutSeconds": 0,
// Maximum execution time (seconds) for background jobs (scheduler, orchestrator tasks).
// When exceeded, the PowerShell pipeline is stopped and the worker is reclaimed.
// 0 = no timeout (default). Recommended: 600-3600 for background jobs.
"BgTimeoutSeconds": 0,
// Extra env vars injected into every runspace.
// Use "{ApiBasePath}" as a placeholder — replaced with the resolved API directory.
"EnvVars": {
"CIPPNG": "true",
"MyCustomVar": "value"
},
// Additional env var names set to the API root path (alongside $env:CRAFT_ROOT).
// Lets existing scripts use their own root variable without code changes.
"RootPathVars": ["CIPPRootPath", "CIPPRoot"],
// Scripts run once on the first worker for process-level warmup.
// Errors are non-fatal (logged as warnings). Runs after module import.
"WarmupScripts": [
"Get-MyAuth | Out-Null",
"$null = Get-Tenants -IncludeErrors"
],
// .NET assemblies to load into each runspace (paths relative to API/).
"SharedAssemblies": [
"Shared/CIPPSharp/bin/CIPPSharp.dll"
],
// Inject shared Synchronized Hashtables into module scopes.
// Enables cross-runspace state sharing (e.g. token caches).
"ModuleInjections": [
{
"Module": "CIPPCore", // Target module name
"Variable": "classictoken", // $script:classictoken inside the module
"CacheKey": "ClassicTokenCache" // Shared key — same key = same hashtable
}
],
// Scripts run after init on EVERY worker (not just the first).
"PostInitScripts": [],
// Module directory names to skip during import.
// Use for test modules or legacy entrypoints you don't want loaded.
"SkipModules": ["CippEntrypoints"],
// JSON files to preload into PowerShell variables at startup.
"JsonPreloads": [
{
"File": "Config/function-permissions.json", // Relative to API/
"Variable": "CIPPFunctionPermissions", // Variable name (no $ prefix)
"Scope": "global", // "global" → $global:Var, "env" → $env:Var (raw JSON string)
"AsHashtable": true // true → case-insensitive Hashtable, false → PSObject
}
]
}Memory impact: Each worker consumes ~100–200 MB depending on module count. A config with HttpPoolSize: 2 + BgPoolSize: 4 uses ~6 workers × 150 MB ≈ 900 MB baseline. Scale accordingly.
Startup behavior: The first HTTP worker initializes sequentially and runs WarmupScripts (which set process-level state like auth tokens and caches). All remaining workers (HTTP + BG) initialize in parallel afterward, benefiting from the process-level state already set. With source (non-compiled) modules, parallel init significantly reduces startup time.
Controls authentication and the dev-mode auto-login principal.
"Auth": {
// Cookie name for authenticated sessions
"CookieName": "craft-session",
// Azure Table used for user → role mappings
"UserTableName": "allowedUsers",
// Storage connection for the allowedUsers table.
// If empty, uses AzureWebJobsStorage (same storage as the rest of the app).
// Set this to point to a separate storage account for isolation.
"UserStorageConnection": "",
// Roles assigned to the auto-login principal in Development mode.
// Production uses real Azure AD auth — this is dev-only.
"DevRoles": ["superadmin", "admin", "editor", "readonly", "authenticated", "anonymous"],
// User ID GUID for the dev principal
"DevUserId": "00000000-0000-0000-0000-000000000000",
// UPN/email for the dev principal
"DevUserDetails": "developer@localhost",
// PowerShell function dispatched for /api/me.
// Empty string = use the literal "me" as the endpoint name.
// The PS function (or its MeEndpointHandler wrapper) owns the response shape —
// /api/me passes status code and body through unchanged.
"MeEndpointFunction": "me",
// Optional wrapper PS function invoked for /api/me instead of MeEndpointFunction directly.
// When set, the handler receives the standard Request/TriggerMetadata parameters and is
// expected to dispatch internally based on Request.Params.CIPPEndpoint (which is set to
// MeEndpointFunction). When empty (default), MeEndpointFunction is invoked directly.
// Example (CIPP): "New-CippCoreRequest"
"MeEndpointHandler": "",
// When true (default), any user who authenticates against the configured Azure AD tenant
// is allowed in — even if they're not in the allowedUsers table.
// Users not in the table get ["authenticated", "anonymous"] as default roles.
// The hosted app can then do its own role resolution (e.g. via Entra group mapping).
// When false, only users explicitly listed in the allowedUsers table can log in.
// Override via env var: App__Auth__AllowAllTenantUsers=false
"AllowAllTenantUsers": true
}Drives the cron-based background task system.
"Scheduler": {
// JSON file containing task definitions (looked up in API/Config/ then API/).
// Must be a JSON array of task objects with Id, Command, Cron, Priority, etc.
"ConfigFile": "CIPPTimers.json",
// How often (seconds) the scheduler checks for due tasks.
"CheckIntervalSeconds": 30
}Task file format (e.g. CIPPTimers.json):
[
{
"Id": "unique-guid",
"Command": "Start-MyOrchestrator",
"Description": "Nightly data collection",
"Cron": "0 0 3 * * *",
"Priority": 10,
"RunOnProcessor": true,
"IsSystem": true
}
]- Cron — 6-field format (seconds included):
sec min hour day month weekday - Priority — Lower number = higher priority in the job queue
- IsSystem — System tasks can't be disabled from the UI
How Command resolves. Native-first: if a scanned assembly ships a
[CraftScheduledTask("<Command>")] class (see Native C# endpoints),
the timer fires that; otherwise the PowerShell script table is consulted, exactly as before. A name
present in both worlds fires the native task and logs the shadowed PowerShell function — same rule,
same visibility, as route collisions. IsOrchestratorOverride is not supported on native commands
(the planner/task split is a PowerShell construct; native code does its own fan-out) and is rejected
at load with an error naming the timer.
Gates how many background/orchestrator tasks run at once, on top of the Worker.BgPoolSize runspaces. These
are root-level config keys (set at the top of appsettings.json or as env vars, not under App:).
By default it starts narrow and ramps slowly, to keep idle memory low; tune it for bursty fan-out.
| Key | Default | Effect |
|---|---|---|
BackgroundBaseConcurrency |
clamp(cores, 2, 4) |
starting width when idle |
BackgroundScaleUpAfterSeconds |
15 |
how long the queue must be backed up before ramping (doubles per 10s tick) |
BackgroundMaxConcurrency |
BgPoolSize |
ceiling |
BackgroundBurstToCeiling |
false |
jump straight to the ceiling the moment tasks queue, skipping the ramp — ~2.7× faster fan-out for bursts shorter than the ramp dwell (see docs/orch-analysis.md) |
BackgroundOverSubscribe |
0 |
admit this many tasks above the ceiling so they can do their pre-invoke table write and queue at the worker checkout while the pool stays full (helps only up to Azure Table write throughput) |
BackgroundHttpPressureThreshold |
HttpPoolSize/2 |
busy-HTTP-worker count that throttles BG to 2; 0 disables |
BackgroundHttpPressureAfterSeconds |
10 |
how long HTTP pressure must persist before throttling |
// top level of appsettings.json (NOT under "App"):
"BackgroundBurstToCeiling": true, // fill the pool immediately on a fan-out burst
"BackgroundScaleUpAfterSeconds": 5 // or: ramp sooner without going straight to ceilingFan-out/fan-in task execution with crash recovery.
"Orchestrator": {
// Prefix for Azure Tables: {Prefix}Runs, {Prefix}Tasks, {Prefix}Results
"TablePrefix": "Orchestrator",
// Batch + coalesce per-task/run STATUS writes off the fan-out critical path, in ≤100-entity byte-budgeted
// Azure Table transactions. Default true. This is the throughput fix for large fan-outs — the per-task
// table write was the ceiling (see docs/orch-analysis.md). Results are NEVER batched (their chunking /
// multi-row large-payload path is untouched). Set false to fall back to per-task writes.
"BatchStatusWrites": true,
// Write the pre-invoke "Running" marker under a durable barrier (persisted BEFORE the task runs, batched
// with concurrently-starting tasks) so AttemptCount/MaxRetries still bounds poison tasks. Default true.
// False = eventual: the marker rides the periodic flush and the task doesn't wait — max throughput (100%
// pool utilization) at the cost of the strict poison-before-invoke guarantee. Terminal + run states stay
// durable in both modes (flushed before a run finalizes and on shutdown).
"DurableRunningBarrier": true,
// Status-writer flush interval / barrier latency ceiling (ms). Default 25.
"StatusFlushIntervalMs": 25,
// PS function that executes individual tasks. Receives TaskJson parameter.
// Default: "Invoke-CraftTask" (provided in CraftRuntime/)
"GenericTaskFunction": "Invoke-CraftTask",
// PS function for queue-triggered commands. Receives Cmdlet + ParametersJson.
// Default: "Invoke-CraftQueueTask" (provided in CraftRuntime/)
"QueueTaskFunction": "Invoke-CraftQueueTask",
// PS function for post-execution aggregation. Receives FunctionName + ResultsPath, where
// ResultsPath is a JSON Lines file (one task result per line) that the host writes before
// the call and deletes after it. Read it a line at a time: it holds the whole run's results,
// 50-150MB on large runs, and not materialising that as a string is the point of passing a
// path. A replacement must declare -ResultsPath; the former -ResultsJson parameter is gone.
// Default: "Invoke-CraftPostExecution" (provided in CraftRuntime/)
"PostExecFunction": "Invoke-CraftPostExecution",
// Max task interruptions (host crash/restart) before marking Failed.
"MaxRetries": 3,
// Retention sweep over the three tables. A run that finished — or that nothing is driving and that
// last wrote to storage — longer ago than RetentionHours is removed together with its Tasks/Results
// partitions, as is any Tasks/Results partition whose Run row is already gone. Runs once at startup
// (after crash recovery) and then every CleanupIntervalHours; 0 keeps only the startup pass. Craft
// needs the rows only while a run is live — the retention is for operators reading recent history.
"RetentionHours": 48,
"CleanupIntervalHours": 4
}All three function settings have sensible defaults provided by the CraftRuntime/ scripts. Most apps only need to set TablePrefix.
A table that goes missing while the host is running — deleted through table maintenance, or by a reset that cleared the orchestrator's state — is recreated by the next read or write that notices it, and that operation is retried. If the table was only just dropped, the service refuses to recreate it for a while (Azure documents "at least 40 seconds"; about a minute in practice) and the operation waits that out, up to two minutes. A batch write into a missing table used to fail silently until the next restart.
Queuing orchestrator runs from PowerShell:
Call Start-CraftOrchestrator (provided in CraftRuntime/) to queue a fan-out run:
Start-CraftOrchestrator -InputObject @{
OrchestratorName = 'MyDataCollection'
Batch = @(
@{ FunctionName = 'CollectData'; TenantFilter = 'contoso.com' }
@{ FunctionName = 'CollectData'; TenantFilter = 'fabrikam.com' }
)
PostExecution = @{ FunctionName = 'AggregateResults' }
}This bridges into the C# OrchestratorService via OrchestratorBridge. Applications can provide their own wrapper function (e.g. CIPP uses Start-CIPPOrchestrator for dual-boot compatibility) — just have it call [Craft.Services.OrchestratorBridge]::QueueOrchestration() internally. A wrapper must pass the parent run name explicitly (read RunName from the stamped $global:CraftOperationContext, as Start-CraftOrchestrator does) — the bridge cannot discover it on its own from the pipeline thread, and without it a parent run finalizes (and runs its PostExecution) before the child runs it spawned complete.
How orchestration works:
- A scheduler task or HTTP endpoint calls
Start-CraftOrchestratorwith a batch - The PS bridge queues it to the C#
OrchestratorServiceviaOrchestratorBridge - Tasks are dispatched through the
JobManagerwith priority ordering - State is persisted to Azure Table Storage after every change
- On restart, interrupted tasks resume automatically
- After all tasks finish, optional
PostExecutionaggregates results and can start a second phase
In-memory index + disk-backed (_cache/) response cache for HTTP List* GET endpoints.
"Cache": {
// Whether the cache is active. Omit (default) for auto: ON only when this node serves BOTH a browser UI
// and its API (combined / frontend+http roles), OFF for api-only, worker-only and static-only nodes.
// Set true/false to force it. Env override (wins): CRAFT_RESPONSE_CACHE=true/false.
// When disabled, no _cache/ directory is created or scanned and all get/set operations are no-ops.
// "Enabled": true,
// Bytes budget for the in-memory body tier (LRU over the disk cache) — a HIT returns the body from RAM
// instead of re-reading + re-decoding the file. Default 64 MiB; 0 = disk-only. Gain scales with response
// size (+44% throughput for small List* responses, +157% at ~150KB; see docs/cache-analysis.md).
"MaxMemoryBytes": 67108864,
// Maximum cached responses held in memory
"MaxEntries": 1000,
// Default TTL (seconds) for cached responses
"DefaultTtlSeconds": 600,
// Query parameter that triggers cache invalidation when "true"
"InvalidateParam": "InvalidateCIPPCache",
// Query parameter for scoped invalidation (e.g. per-tenant).
// When a write clears cache, only entries with matching scope value are evicted.
"ScopeParam": "tenantFilter",
// Endpoints never cached, whatever the query string says.
// Case-insensitive; "*" matches any run of characters.
"ExcludedEndpoints": ["ListLogs", "ListScheduled*"],
// Query parameter a request must carry before its response may be cached at all.
// Empty (default) = every eligible read is cached.
"RequiredParam": "tenantFilter",
// Values of RequiredParam that skip the cache (case-insensitive).
"ExcludedParamValues": ["AllTenants"],
// Request header that bypasses the cache for a single call. Empty disables the check.
"NoCacheHeader": "x-craft-no-cache",
// Per-endpoint TTL overrides (seconds). Key = endpoint name.
"EndpointTtl": {
"ListTenants": 300,
"ListUsers": 120
}
}A response is cached only when both gates agree:
- The handler is a side-effect-free read — a
GETto aList*endpoint. This is the naming convention, and it is not configurable. - The request passes the admission policy below.
The policy is evaluated in this order, and the first failure wins:
| Gate | X-Cache-Bypass when it fails |
|---|---|
Endpoint matches ExcludedEndpoints |
excluded-endpoint |
NoCacheHeader sent with any value other than false/0/no |
no-cache-header |
RequiredParam missing from the query string |
missing-required-param |
RequiredParam present but blank |
empty-required-param |
RequiredParam value listed in ExcludedParamValues |
excluded-param-value |
A bypassed request neither reads from nor writes to the cache, so it can never collide with an entry
stored by a differently-scoped caller, and it answers with X-Cache: BYPASS. X-Cache: MISS still
means what it always did — the request was eligible and there was simply no entry for it.
All of these are inert by default (RequiredParam empty, no excluded values, no excluded endpoints),
so an existing deployment that upgrades keeps caching exactly what it cached before until it opts in.
ExcludedEndpoints vs RequiredParam. They cover different cases and are worth using together.
RequiredParam classifies in bulk: everything that does not take the scoping parameter drops out
without anyone having to enumerate it. ExcludedEndpoints handles the exceptions that rule cannot see
— an endpoint that does take tenantFilter and is still a poor cache candidate, because it is
cheap, near-realtime, or answered per user rather than per tenant. Patterns accept * anywhere
(ListLog*, *Logs, List*Audit*), matched case-insensitively against the endpoint name as it
appears in the route.
Why require a parameter at all. List* endpoints that take no scope parameter — a tenant list, a
log tail, the scheduler view — are usually fast, query-shaped and answered per user. Caching them buys
little and invites key collisions between users whose results legitimately differ. Requiring
tenantFilter keeps the cache to the calls where it pays for itself, and, because every cached key
then contains tenantFilter=…, it also makes ScopeParam invalidation exact.
Per-call bypass:
curl -H "x-craft-no-cache: true" https://example/API/ListUsers?tenantFilter=contoso.comControls where the host discovers PowerShell scripts.
"Scripts": {
// Module directories (under API/Modules/) scanned for HTTP endpoint functions.
// All HTTP-category functions become /api/{route} endpoints.
// If a function starts with "Invoke-", that prefix is stripped for the route.
"HttpModules": ["CIPPHTTP"],
// Optional global HTTP handler. When set, ALL /API/{endpoint} routes dispatch through
// this PS function instead of invoking Invoke-{endpoint} directly. The endpoint name
// is passed via Request.Params.CIPPEndpoint so the handler can dispatch internally.
// Use when the hosted app expects all routes to go through a common router
// (e.g. CIPP's New-CippCoreRequest which performs Test-CIPPAccess + telemetry).
"HttpHandler": "New-CippCoreRequest",
// Directories (under API/) scanned for background scripts.
// These are deployed as Function:\ items on each worker.
"BackgroundScriptDirs": [],
// RBAC metadata extraction from comment-based help (.ROLE, .FUNCTIONALITY)
"PermissionExtraction": {
"Enabled": true,
"Modules": ["CIPPHTTP"],
"OutputFile": "Config/function-permissions.json"
}
}HTTP endpoints and scheduled tasks written in C#, hosted alongside the PowerShell ones. Off unless you name the assemblies to scan; costs nothing when off.
"Endpoints": {
"Enabled": true,
// Absolute, or relative to the API base path.
"Assemblies": ["bin/MyApp.dll"],
// What to do when a native endpoint claims a route a PowerShell function already has:
// PreferNative (default) | PreferPowerShell (instant rollback) | Fail (right for CI).
"OnCollision": "PreferNative",
// Refuse to start when Central-dispatch endpoints exist but no ICraftEndpointHandler was found.
// Default false. Set true (in CI at minimum) when authorization lives in the central handler.
"RequireHandler": false,
// Blanket in-flight limit for endpoints that declare none, and per-route overrides.
// With HttpPoolSize=0 nothing else caps concurrent work — see the comments in EndpointSettings.cs.
"MaxConcurrency": 0,
"Concurrency": { "GeoDBDownload": 4 },
"QueueTimeoutSeconds": 0
}The assembly scan discovers four things:
| Contract | Purpose |
|---|---|
ICraftEndpoint + [CraftEndpoint("Route")] |
An HTTP endpoint at /API/{Route} |
ICraftEndpointHandler |
THE central entrypoint — at most one per app |
ICraftScheduledTask + [CraftScheduledTask("Command")] |
A scheduled task fired from the timer file |
ICraftServiceModule |
DI registrations for an app with no Program.cs of its own |
The central handler. The native counterpart of Scripts:HttpHandler: one place every
authenticated API call funnels through (resolve principal → check role/plan → invoke, or refuse).
It wraps the endpoint (HandleAsync(request, invokeEndpoint, ct)), so it can short-circuit,
pass through, or post-process. Endpoints opt out per-route in code:
[CraftEndpoint("StripeWebhook", Dispatch = EndpointDispatch.Direct)] // signature IS the auth
[CraftEndpoint("QrRedirect", Dispatch = EndpointDispatch.Direct)] // deliberately anonymous
[CraftEndpoint("EditLink", Role = "qr.edit")] // Central (default) — handler
// reads Role off request.EndpointDispatch defaults to Central, so a route that never thought about it gets the application's
authorization rather than becoming accidentally public. Direct routes are listed in the startup log —
that line is the security-review checklist. Two handlers fail startup (which one ran would be
assembly scan order); zero is legal and means every endpoint dispatches direct, exactly the
pre-handler behaviour. ICraftEndpointFilter still runs before every endpoint including Direct
ones — filters are for telemetry and throttling that public routes must not escape; the handler is
for authorization, which is precisely what a public route opts out of.
Native scheduled tasks. Fired by the scheduler from the same timer file as PowerShell commands
(Command resolves native-first — see Scheduler), enqueued through the same
JobManager, so priority ordering, job records and the background concurrency limiter apply
unchanged. They run on the .NET thread pool: no runspace is involved, which is what makes the
all-native configuration real —
Worker:HttpPoolSize=0 no HTTP runspaces (all routes native)
Worker:BgPoolSize=0 no BG runspaces (all scheduled work native)
With both at 0 the container hosts no PowerShell at all and startup logs
No PowerShell pools — native endpoints/tasks only. One semantic difference to design for:
Worker:BgTimeoutSeconds stops a PS pipeline forcibly, but a native task is cancelled
cooperatively — pass the token to everything that accepts one, because a task that ignores it
runs on.
Identity-gated Server-Sent Events channel at /.craft/events, fed in-process by
[Craft.Services.RealtimeBridge]::Publish(...) from PowerShell. See realtime-bridge-plan.md.
Off by default — opt in. Set Enabled: true (or CRAFT_REALTIME_ENABLED=true, which wins over config).
While off the endpoint is not mapped, Publish calls are no-ops, and no state or timer is held. When on, the
endpoint is still only mapped by nodes carrying the Http or Frontend role.
"Realtime": {
"Enabled": false, // opt-in switch — set true to serve /.craft/events
// Tuning (defaults shown)
"MaxMessageBytes": 16384, // per-event data cap; over this the payload is dropped and a 413 frame is sent
"MaxActiveJobs": 10000, // max stored (userId, jobId) entries
"MaxConnections": 1000, // max concurrent SSE streams
"PerConnectionQueue": 256, // buffered frames per connection before the oldest is dropped
"HeartbeatSeconds": 20, // keep-alive comment interval
"EntryTtlMinutes": 60 // backstop eviction for jobs that never send "end"
}EasyAuth handles auth, redirects, and excluded paths at the App Service platform layer (see Setup.UnauthenticatedClientAction and Setup.ExcludedPaths). CRAFT only adds response headers EasyAuth doesn't touch — currently just CSP.
"Frontend": {
// Content-Security-Policy applied to all responses. Null/empty = no CSP set.
"ContentSecurityPolicy": "default-src 'self' https: blob: 'unsafe-eval' 'unsafe-inline'; connect-src 'self' https: blob: data:; object-src 'self' blob:; img-src 'self' blob: data: *"
}If you override this, keep 'self' in both default-src and connect-src. A policy that only lists the https: scheme blocks the app's own same-origin fetch calls whenever it is reached over http — behind a TLS-terminating proxy, self-hosted, or in local docker. 'self' permits exactly one origin, so it does not widen a policy that already allows every https: host.
Keep data: in connect-src as well. Emscripten SINGLE_FILE builds inline their WebAssembly as a data:application/octet-stream;base64,… URL and then fetch it, which connect-src gates — wasm-backed layout and parsing libraries are commonly shipped this way. Such loaders normally fall back to decoding the base64 in JavaScript, so blocking it costs a failed request and a console error rather than breaking the feature, but there is no reason to pay for it. It belongs in connect-src and not in default-src, which would also hand data: to script-src.
These are process-level variables read directly (not part of App:*):
| Variable | Default | Description |
|---|---|---|
CRAFT_LOG_LEVEL |
Information |
Minimum log level for file/console output and PowerShell stream capture. Values: Trace, Debug, Information, Warning, Error, Critical. At Debug, Write-Debug is captured; at Trace, Write-Verbose is also captured. Overrides App:FileLogging:LogLevel in appsettings. |
CRAFT_ROOT |
(auto-set) | API base path — set automatically, available as $env:CRAFT_ROOT in PS |
CRAFT_DEV_FRONTEND_URL |
http://localhost:3000 |
Dev mode: proxy frontend requests to this URL (hot-reload) |
CRAFT_REALTIME_ENABLED |
(unset) | true/false — realtime SSE channel at /.craft/events. Overrides App:Realtime:Enabled (which defaults to off) |
AzureWebJobsStorage |
(required) | Azure Storage connection string for Table Storage |
ASPNETCORE_ENVIRONMENT |
Production |
Development enables dev auth, dev errors, frontend proxy |
These configure the built-in Azure AD / Entra ID OIDC authentication. When set, Craft handles login, token validation, session cookies, and user authorization via the allowedUsers Azure Table.
| Variable | Required | Description |
|---|---|---|
WEBSITE_AUTH_CLIENT_ID |
Yes (prod) | Azure AD app registration client/application ID |
AUTH_SECRET |
Yes (prod) | Azure AD client secret for the app registration |
WEBSITE_AUTH_AAD_ALLOWED_TENANTS |
No | Tenant ID to restrict logins to. Defaults to common (any tenant) |
When none of these are set, Craft's auth is unconfigured — the /login endpoint returns an error and API requests receive no identity header. In Development mode, a dev principal is injected automatically (see Auth.DevRoles).
These variable names are intentionally compatible with Azure App Service's built-in authentication headers so that the same configuration works whether auth is handled by Craft directly or by the App Service platform.
In production on Azure App Service, do not put secrets in plain-text App Settings. Use Key Vault references instead:
WEBSITE_AUTH_CLIENT_ID = @Microsoft.KeyVault(VaultName=myvault;SecretName=ApplicationID)
AUTH_SECRET = @Microsoft.KeyVault(VaultName=myvault;SecretName=ApplicationSecret)
WEBSITE_AUTH_AAD_ALLOWED_TENANTS = @Microsoft.KeyVault(VaultName=myvault;SecretName=TenantID)
Key Vault references require:
- The App Service has a System-Assigned Managed Identity enabled
- The Key Vault has an access policy granting that identity
Getpermission on secrets - The App Setting values use the
@Microsoft.KeyVault(...)syntax exactly as shown
App Service resolves these at startup and injects the secret values as environment variables. Craft reads them the same way regardless of whether they are plain values or KV references — it's transparent.
Note: Key Vault access in Craft uses access policies, not Azure RBAC for Key Vault. Ensure the Key Vault has access policies enabled (the default), not "Azure role-based access control" as the permission model.
The allowedUsers table works the same way as Azure Static Web Apps user invitations — it's an application-level authorization layer that maps Azure AD identities to app-specific roles. This is separate from Azure AD group membership or app roles; it gives the application full control over who can access it and with what permissions.
By default, the table lives in the same storage account as the rest of the app (AzureWebJobsStorage). To isolate it — for example, to share a single user table across multiple Craft instances, or to keep user data in a separate storage account from operational data — set Auth.UserStorageConnection to a different connection string.
In appsettings.json:
"Auth": {
"UserStorageConnection": "DefaultEndpointsProtocol=https;AccountName=myuserstorage;AccountKey=..."
}Or via App Settings with a Key Vault reference:
App__Auth__UserStorageConnection = @Microsoft.KeyVault(VaultName=myvault;SecretName=UserStorageConnection)
Login flow:
- User visits
/login(or/.auth/login/aad) → redirected to Azure AD - Azure AD authenticates user → redirects to
/.auth/callbackwith authorization code - Craft exchanges the code for tokens, validates the
id_tokenJWT signature against Azure AD's published signing keys - User's UPN is checked against the
allowedUsersAzure Table for role mapping (or assigned default roles ifAllowAllTenantUsersis enabled) - An encrypted session cookie is set (AES-256, derived from
AUTH_SECRET) - The
x-ms-client-principalheader is injected on subsequent requests (SWA-compatible format)
Session details:
- Cookie name is configurable via
Auth.CookieName(default:craft-session) - Sessions are stored in-memory with an 8-hour TTL (token expiry + 1 hour grace)
- Cookie is
HttpOnly,Secure,SameSite=Lax
Auth header formats — Craft handles three scenarios:
| Source | Detection | Behavior |
|---|---|---|
| App Service EasyAuth | x-ms-client-principal with claims array, no userRoles |
Transforms to SWA format, adds roles from allowedUsers table |
| Azure SWA | x-ms-client-principal with userRoles array |
Passes through as-is |
| Craft session cookie | No x-ms-client-principal header, valid session cookie |
Builds and injects the header from the validated session |
This means Craft works identically whether deployed standalone (container), behind Azure App Service authentication, or behind Azure Static Web Apps — downstream PowerShell always sees the same x-ms-client-principal header format.
The allowedUsers Azure Table (name configurable via Auth.UserTableName) maps user identities to roles:
| Column | Value |
|---|---|
PartitionKey |
Any partition value (e.g. "User") |
RowKey |
User's UPN / email (e.g. admin@contoso.com) |
Roles |
JSON array of role strings: ["admin", "editor"] |
Roles are cached in-memory for 5 minutes. If a user is not in the table, behavior depends on Auth.AllowAllTenantUsers:
true(default): User is allowed in with default roles["authenticated", "anonymous"]. The hosted application can perform its own role resolution (e.g. CIPP maps Entra group membership to CIPP roles via/api/me). Users explicitly listed in the table still get their table-defined roles.false: User is denied access (401). Only users explicitly listed in the allowedUsers table can log in.
When using Craft's built-in auth, the Azure AD app registration must include these redirect URIs:
https://<your-host>/.auth/callback
If your application also uses OAuth flows (e.g. SAM token refresh), add those redirect URIs to the same app registration as needed.
The CIPP application's appsettings.Development.json shows a full real-world configuration:
{
"App": {
"Name": "CIPP",
"Worker": {
"HttpPoolSize": 2,
"BgPoolSize": 4,
"RootPathVars": ["CIPPRootPath", "CIPPRoot"],
"EnvVars": { "CIPPNG": "true" },
"WarmupScripts": [
"if (-not $env:ApplicationID) { Get-CIPPAuthentication | Out-Null }",
"$null = Get-Tenants -IncludeErrors"
],
"SharedAssemblies": ["Shared/CIPPSharp/bin/CIPPSharp.dll"],
"ModuleInjections": [
{ "Module": "CIPPCore", "Variable": "classictoken", "CacheKey": "ClassicTokenCache" }
],
"SkipModules": ["CippEntrypoints"],
"JsonPreloads": [
{ "File": "Config/function-permissions.json", "Variable": "CIPPFunctionPermissions", "Scope": "global", "AsHashtable": true },
{ "File": "Config/cipp-roles.json", "Variable": "CIPPBaseRoles", "Scope": "global" }
]
},
"Auth": {
"CookieName": "cipp-session",
"DevRoles": ["superadmin", "admin", "editor", "readonly", "authenticated", "anonymous"],
"MeEndpointFunction": "me",
"MeEndpointHandler": "New-CippCoreRequest",
"AllowAllTenantUsers": true
},
"Scheduler": { "ConfigFile": "CIPPTimers.json" },
"Orchestrator": {
"TablePrefix": "CippOrchestrator"
},
"Cache": {
"InvalidateParam": "InvalidateCIPPCache",
"ScopeParam": "tenantFilter",
"RequiredParam": "tenantFilter",
"ExcludedParamValues": ["AllTenants"],
"ExcludedEndpoints": ["ListLogs", "ListScheduledItems"]
},
"Scripts": {
"HttpModules": ["CIPPHTTP"],
"BackgroundScriptDirs": [],
"PermissionExtraction": {
"Enabled": true,
"Modules": ["CIPPHTTP"],
"OutputFile": "Config/function-permissions.json"
}
}
}
}Craft separates its own built-in scripts from application content:
/app/
├── Runtime/ ← Craft built-in (ships with the base image)
│ ├── CraftRuntime/ ← Orchestrator/queue/task scripts
│ └── HTTP/Exec/ ← Built-in admin endpoint
├── API/ ← Application content (downstream overlay)
│ ├── Modules/ ← PowerShell modules
│ ├── Config/ ← App config files (timers, permissions, etc.)
│ └── Shared/ ← Shared assemblies
└── Frontend/ ← Static frontend build
Runtime/ is owned by Craft and always loaded. API/ is 100% owned by the downstream application — safe to volume-mount in dev without overwriting Craft internals.
- Place compiled PS modules in
API/Modules/ - Place frontend build in
Frontend/(static files served automatically) - Configure
App:settings — eitherApp__*environment variables (preferred for containers) or your ownappsettings.json, usingappsettings.example.jsoncas the reference. Set only what you're changing; everything else falls back to the defaults inServices/CraftSettings.cs. - Set
AzureWebJobsStorageto a valid Azure Storage connection string (orUseDevelopmentStorage=truefor Azurite) - Run:
dotnet runordocker compose up
The host auto-discovers modules, builds route tables from HTTP endpoint functions, starts the scheduler, and serves both API and frontend from a single process.
{ "App": { "Name": "MyApp", // Display name used in logs and diagnostics // Controls when Kestrel starts accepting connections: // "Immediate" — Kestrel starts first, init runs in background (default, fast startup) // "HttpReady" — Kestrel starts after HTTP worker pool is ready // "AllReady" — Kestrel starts after all worker pools are fully initialized "ReadinessMode": "Immediate", // Kestrel request timeout (seconds). Controls how long Kestrel waits for a complete // request (headers + body) before aborting. Does NOT control PowerShell execution time. // If not set (or 0): derives from Worker.HttpTimeoutSeconds if > 0, else no timeout. // Recommended: set slightly higher than HttpTimeoutSeconds to give workers time to respond. // Example: HttpTimeoutSeconds=120, KestrelTimeoutSeconds=130 "KestrelTimeoutSeconds": 0 } }