diff --git a/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md b/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md
new file mode 100644
index 000000000..f57c4a73c
--- /dev/null
+++ b/docs/implplan/SPRINT_20260324_001_Platform_scripts_variables_validation_diff_compat.md
@@ -0,0 +1,172 @@
+# Sprint 20260324_001 — Scripts Library Enhancement: Variables, Validation, Diff, Compatibility
+
+## Topic & Scope
+- Add per-target variable/secret declarations to scripts, with editable UI and Monaco completions.
+- Enhance Compile/Validate with real lint checks (safety, variable refs, structure) and Monaco diagnostic markers.
+- Add version diff viewer using Monaco diff editor for side-by-side version comparison.
+- Add deployment compatibility checker (language-target matrix, variable resolution, secret availability).
+- Working directory: `src/Platform/StellaOps.Platform.WebService/`, `src/Web/StellaOps.Web/src/app/`.
+- Expected evidence: backend build success, frontend build success, e2e tests pass (T10.1–T10.6).
+
+## Dependencies & Concurrency
+- No upstream sprint dependencies.
+- All 5 phases implemented in a single batch (Phase 1 models first, Phases 2-5 in parallel).
+
+## Documentation Prerequisites
+- None — self-contained feature enhancement.
+
+## Delivery Tracker
+
+### TASK-001 - Phase 1: Models & Contracts
+Status: DONE
+Dependency: none
+Owners: Developer
+Task description:
+- Add `ScriptVariableDeclaration`, `ScriptVersionDetailDto`, `CheckCompatibilityRequest`, `CompatibilityResultDto`, `CompatibilityIssue` records to backend contracts.
+- Add `Variables` field to `ScriptDetail`, `ScriptSummary`, `CreateScriptApiRequest`, `UpdateScriptApiRequest`.
+- Add `DeclaredVariables` to `ValidateScriptApiRequest`, `Category` to `ScriptDiagnosticDto`.
+- Add corresponding TypeScript interfaces to frontend models.
+- Extend `IScriptService` with `GetVersionContentAsync` and `CheckCompatibilityAsync`.
+- Extend `ScriptsApi` interface with `getVersionContent`, `checkCompatibility`, and `declaredVariables` param.
+
+Completion criteria:
+- [x] All backend DTOs compile
+- [x] All frontend interfaces defined
+- [x] Service interface extended
+
+### TASK-002 - Phase 2: Per-Target Variables & Secrets
+Status: DONE
+Dependency: TASK-001
+Owners: Developer
+Task description:
+- Propagate variables through create/update in `InMemoryScriptService`.
+- Add sample variables to seed scripts (SERVICE_URL, TIMEOUT, DB_CONNECTION, IMAGE_REF, etc.).
+- Add variables editor UI in script-detail with add/remove/edit rows.
+- Show user variables in script editor context panel with click-to-insert.
+- Register user variable completions alongside system vars in Monaco.
+
+Completion criteria:
+- [x] Variables persisted through create/update cycle
+- [x] Mock scripts include sample variables
+- [x] Variables editor renders and modifies signal state
+- [x] Context panel shows user variables
+- [x] Monaco autocomplete includes user variables on `$` trigger
+
+### TASK-003 - Phase 3: Enhanced Compile/Validate
+Status: DONE
+Dependency: TASK-001
+Owners: Developer
+Task description:
+- Variable reference validation: scan for `${STELLA_*}`, `$env:STELLA_*`, `Environment.GetEnvironmentVariable("STELLA_*")`.
+- Bash: warn if missing `set -euo pipefail`; error on `rm -rf /`.
+- C#: warn on empty catch blocks.
+- General: hardcoded IPs, TODO/FIXME, >500 lines.
+- Improved brace matching with line numbers.
+- Frontend: pass declared variables to validate, set Monaco diagnostic markers.
+
+Completion criteria:
+- [x] Backend validation returns categorized diagnostics
+- [x] Frontend mock validates bash variable refs and safety
+- [x] Monaco editor shows squiggly underlines from diagnostics
+- [x] Markers cleared on content change
+
+### TASK-004 - Phase 4: Script Version Diff Viewer
+Status: DONE
+Dependency: TASK-001
+Owners: Developer
+Task description:
+- Store version content in `TenantState.VersionContents` on create/update.
+- Implement `GetVersionContentAsync` and GET endpoint.
+- Create `ScriptDiffComponent` with Monaco diff editor.
+- Add route `:scriptId/diff` and "Compare" links in version history.
+
+Completion criteria:
+- [x] Version content stored per version number
+- [x] GET endpoint returns version content
+- [x] Diff component loads both versions and renders Monaco diff editor
+- [x] Compare links navigate with correct query params
+
+### TASK-005 - Phase 5: Deployment Compatibility Check
+Status: DONE
+Dependency: TASK-001
+Owners: Developer
+Task description:
+- Language-target matrix (bash/ECS warn, PowerShell/Linux warn).
+- Variable resolution against target metadata, secret availability check.
+- Runtime notes for ECS and Nomad targets.
+- POST endpoint for compatibility check.
+- Compatibility panel in script-detail with target type dropdown and results.
+
+Completion criteria:
+- [x] Backend returns categorized compatibility issues
+- [x] POST endpoint wired
+- [x] UI panel toggles, sends request, displays results
+- [x] Mock client implements same matrix logic
+
+### TASK-006 - Version restore (edit older version)
+Status: DONE
+Dependency: TASK-004
+Owners: Developer
+Task description:
+- Add "Edit" button on each non-current version in version history.
+- Load older version content into Monaco editor via `getVersionContent()` API.
+- Show warning banner when editing an old version.
+- Show confirmation modal on save — "this will create a new latest version".
+- Add `setContent()` method to ScriptEditorComponent.
+
+Completion criteria:
+- [x] Edit button loads older version content into editor
+- [x] Warning banner visible with "Back to latest" dismiss
+- [x] Modal blocks save until confirmed
+- [x] After save, editingVersion resets and version list refreshes
+
+### TASK-007 - Input field styling (Stella Ops design system)
+Status: DONE
+Dependency: none
+Owners: Developer
+Task description:
+- Match all form inputs to global search style: warm `surface-tertiary` background, same border/text/height/transition.
+- Fix `box-sizing: border-box` overlap bug (inputs on same row overlapping by ~10px).
+- Increase grid gaps between side-by-side fields (0.5rem → 1rem).
+- Document the input field convention in `src/Web/StellaOps.Web/AGENTS.md`.
+
+Completion criteria:
+- [x] All inputs use surface-tertiary background, border-primary, 34px height, 0.12s transitions
+- [x] box-sizing: border-box on all inputs — no overlap
+- [x] Grid gaps 1rem between fields
+- [x] AGENTS.md updated with Input Field Convention section
+
+## Execution Log
+| Date (UTC) | Update | Owner |
+| --- | --- | --- |
+| 2026-03-24 | Sprint created. All 5 phases implemented. Backend + frontend build clean. | Developer |
+| 2026-03-24 | Docker images rebuilt (platform + console). Stack restarted. | Developer |
+| 2026-03-24 | E2E tests T10.1–T10.6 pass (7/7 including setup). | Developer |
+| 2026-03-24 | TASK-006: Version restore with warning modal implemented. | Developer |
+| 2026-03-24 | TASK-007: Input field styling fixed — warm bg, border-box, spacing. AGENTS.md updated with Input Field Convention. | Developer |
+
+## Decisions & Risks
+- All script storage is in-memory (`InMemoryScriptService`). No database migration required for this sprint.
+- PowerShell not in `ScriptLanguageDto` enum — PowerShell-specific backend validation omitted but variable reference scanning includes `$env:STELLA_*` patterns.
+- Version content storage is append-only within `TenantState`; no cleanup on version limit.
+- Input overlap root cause: `box-sizing: content-box` (browser default) + `padding: 0 0.75rem` + `border: 1px` caused inputs to extend ~26px beyond their grid cell. Fix: `box-sizing: border-box` on all inputs.
+
+## Next Checkpoints
+- Integration with real `ScriptService` backed by PostgreSQL (future sprint).
+- E2E tests for new features (variables editor, diff viewer, compatibility panel) — not yet covered by existing T10 suite.
+
+## Files Modified
+
+| File | Changes |
+|------|---------|
+| `src/Platform/.../Contracts/ScriptApiModels.cs` | +6 new DTOs/records, Variables on Detail/Summary/Requests |
+| `src/Platform/.../Services/IScriptService.cs` | +2 methods |
+| `src/Platform/.../Services/InMemoryScriptService.cs` | Variables storage, enhanced validation, version content, compatibility |
+| `src/Platform/.../Endpoints/ScriptEndpoints.cs` | +2 endpoints (version content, compatibility) |
+| `src/Web/.../core/api/scripts.models.ts` | +5 interfaces |
+| `src/Web/.../core/api/scripts.client.ts` | +3 API methods, mock expansions, variables on scripts |
+| `src/Web/.../features/scripts/script-detail.component.ts` | Variables editor, enhanced compile, compare links, compatibility panel |
+| `src/Web/.../features/scripts/script-diff.component.ts` | **New** — Monaco diff viewer |
+| `src/Web/.../features/scripts/scripts.routes.ts` | +1 route (diff) |
+| `src/Web/.../shared/components/script-editor/script-editor.component.ts` | User variables input, diagnostic markers |
+| `src/Web/.../shared/components/script-editor/script-context.ts` | User variables in completions |
diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
index 574d5b987..c7dbb18d3 100644
--- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformPolicies.cs
@@ -53,4 +53,8 @@ public static class PlatformPolicies
// Identity provider management policies (SPRINT_20260224_100)
public const string IdentityProviderRead = "platform.idp.read";
public const string IdentityProviderAdmin = "platform.idp.admin";
+
+ // Script registry policies (script editor / multi-language scripts)
+ public const string ScriptRead = "platform.script.read";
+ public const string ScriptWrite = "platform.script.write";
}
diff --git a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
index 3aaa41637..83bc1f863 100644
--- a/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Constants/PlatformScopes.cs
@@ -54,4 +54,8 @@ public static class PlatformScopes
// Identity provider management scopes (SPRINT_20260224_100)
public const string IdentityProviderRead = "platform.idp.read";
public const string IdentityProviderAdmin = "platform.idp.admin";
+
+ // Script registry scopes (script editor / multi-language scripts)
+ public const string ScriptRead = "script:read";
+ public const string ScriptWrite = "script:write";
}
diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs
new file mode 100644
index 000000000..abf32f15f
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Contracts/ScriptApiModels.cs
@@ -0,0 +1,212 @@
+using System.Collections.Immutable;
+using System.Text.Json.Serialization;
+
+namespace StellaOps.Platform.WebService.Contracts;
+
+///
+/// Supported script languages (mirrors ReleaseOrchestrator.Scripts.ScriptLanguage).
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ScriptLanguageDto
+{
+ CSharp,
+ Python,
+ Java,
+ Go,
+ Bash,
+ TypeScript
+}
+
+///
+/// Script visibility/access control level.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ScriptVisibilityDto
+{
+ Private,
+ Team,
+ Organization,
+ Public
+}
+
+///
+/// Declared variable for a script (runtime input).
+///
+public sealed record ScriptVariableDeclaration(
+ string Name,
+ string? Description,
+ bool IsRequired,
+ string? DefaultValue,
+ bool IsSecret);
+
+///
+/// Script summary returned in list results (excludes content for efficiency).
+///
+public sealed record ScriptSummary(
+ string Id,
+ string Name,
+ string? Description,
+ ScriptLanguageDto Language,
+ ScriptVisibilityDto Visibility,
+ int Version,
+ IReadOnlyList Tags,
+ IReadOnlyList Variables,
+ string OwnerId,
+ string? TeamId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? UpdatedAt,
+ string ContentHash);
+
+///
+/// Full script detail including content.
+///
+public sealed record ScriptDetail(
+ string Id,
+ string Name,
+ string? Description,
+ ScriptLanguageDto Language,
+ string Content,
+ string? EntryPoint,
+ ScriptVisibilityDto Visibility,
+ int Version,
+ IReadOnlyList Dependencies,
+ IReadOnlyList Tags,
+ IReadOnlyList Variables,
+ string OwnerId,
+ string? TeamId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? UpdatedAt,
+ string ContentHash,
+ bool IsSample,
+ string? SampleCategory);
+
+///
+/// Script dependency reference.
+///
+public sealed record ScriptDependencyDto(
+ string Name,
+ string Version,
+ string? Source,
+ bool IsDevelopment);
+
+///
+/// Script version history entry.
+///
+public sealed record ScriptVersionDto(
+ string ScriptId,
+ int Version,
+ string ContentHash,
+ IReadOnlyList Dependencies,
+ DateTimeOffset CreatedAt,
+ string CreatedBy,
+ string? ChangeNote);
+
+///
+/// Script version with content (for diff).
+///
+public sealed record ScriptVersionDetailDto(
+ string ScriptId,
+ int Version,
+ string ContentHash,
+ string Content,
+ DateTimeOffset CreatedAt,
+ string CreatedBy,
+ string? ChangeNote);
+
+///
+/// Create script request body.
+///
+public sealed record CreateScriptApiRequest
+{
+ public required string Name { get; init; }
+ public string? Description { get; init; }
+ public required ScriptLanguageDto Language { get; init; }
+ public required string Content { get; init; }
+ public string? EntryPoint { get; init; }
+ public IReadOnlyList? Dependencies { get; init; }
+ public IReadOnlyList? Tags { get; init; }
+ public IReadOnlyList? Variables { get; init; }
+ public ScriptVisibilityDto Visibility { get; init; } = ScriptVisibilityDto.Private;
+}
+
+///
+/// Update script request body.
+///
+public sealed record UpdateScriptApiRequest
+{
+ public string? Name { get; init; }
+ public string? Description { get; init; }
+ public string? Content { get; init; }
+ public string? EntryPoint { get; init; }
+ public IReadOnlyList? Dependencies { get; init; }
+ public IReadOnlyList? Tags { get; init; }
+ public IReadOnlyList? Variables { get; init; }
+ public ScriptVisibilityDto? Visibility { get; init; }
+ public string? ChangeNote { get; init; }
+}
+
+///
+/// Validate script request body (validate-only, no save).
+///
+public sealed record ValidateScriptApiRequest
+{
+ public required ScriptLanguageDto Language { get; init; }
+ public required string Content { get; init; }
+ public IReadOnlyList? DeclaredVariables { get; init; }
+}
+
+///
+/// Diagnostic severity for script validation results.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ScriptDiagnosticSeverityDto
+{
+ Info,
+ Warning,
+ Error
+}
+
+///
+/// Script diagnostic entry from validation.
+///
+public sealed record ScriptDiagnosticDto(
+ ScriptDiagnosticSeverityDto Severity,
+ string Message,
+ int Line,
+ int Column,
+ int? EndLine,
+ int? EndColumn,
+ string? Category = null);
+
+///
+/// Script validation result.
+///
+public sealed record ScriptValidationResultDto(
+ bool IsValid,
+ IReadOnlyList Errors,
+ IReadOnlyList Diagnostics);
+
+///
+/// Request to check script compatibility with a target.
+///
+public sealed record CheckCompatibilityRequest
+{
+ public required string TargetType { get; init; }
+ public IReadOnlyDictionary? TargetMetadata { get; init; }
+ public IReadOnlyList? AvailableSecrets { get; init; }
+}
+
+///
+/// Compatibility check result.
+///
+public sealed record CompatibilityResultDto(
+ bool IsCompatible,
+ IReadOnlyList Issues);
+
+///
+/// A single compatibility issue.
+///
+public sealed record CompatibilityIssue(
+ string Category,
+ string Severity,
+ string Message);
diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs
new file mode 100644
index 000000000..c25ec4021
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/ScriptEndpoints.cs
@@ -0,0 +1,410 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using StellaOps.Auth.ServerIntegration.Tenancy;
+using StellaOps.Platform.WebService.Constants;
+using StellaOps.Platform.WebService.Contracts;
+using StellaOps.Platform.WebService.Services;
+
+namespace StellaOps.Platform.WebService.Endpoints;
+
+///
+/// Script registry API endpoints (CRUD, versioning, validation).
+///
+public static class ScriptEndpoints
+{
+ private const int DefaultLimit = 50;
+ private const int MaxLimit = 200;
+
+ public static IEndpointRouteBuilder MapScriptEndpoints(this IEndpointRouteBuilder app)
+ {
+ var scripts = app.MapGroup("/api/v2/scripts")
+ .WithTags("Scripts")
+ .RequireAuthorization(PlatformPolicies.ScriptRead)
+ .RequireTenant();
+
+ // GET /api/v2/scripts - List/search scripts
+ scripts.MapGet(string.Empty, async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ TimeProvider timeProvider,
+ [FromQuery] ScriptLanguageDto? language,
+ [FromQuery] ScriptVisibilityDto? visibility,
+ [FromQuery] string? search,
+ [FromQuery] int? limit,
+ [FromQuery] int? offset,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ var normalizedLimit = NormalizeLimit(limit);
+ var normalizedOffset = NormalizeOffset(offset);
+
+ var items = await service.SearchAsync(
+ requestContext!.TenantId,
+ language,
+ visibility,
+ search,
+ normalizedLimit,
+ normalizedOffset,
+ cancellationToken).ConfigureAwait(false);
+
+ var totalCount = await service.CountAsync(
+ requestContext!.TenantId,
+ language,
+ visibility,
+ search,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(new PlatformListResponse(
+ requestContext.TenantId,
+ requestContext.ActorId,
+ timeProvider.GetUtcNow(),
+ Cached: false,
+ CacheTtlSeconds: 0,
+ items,
+ totalCount,
+ normalizedLimit,
+ normalizedOffset,
+ Query: search));
+ })
+ .WithName("ListScripts")
+ .WithSummary("List and search scripts")
+ .WithDescription("Lists scripts with optional filtering by language, visibility, and search text.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ // POST /api/v2/scripts - Create new script
+ scripts.MapPost(string.Empty, async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ [FromBody] CreateScriptApiRequest request,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ try
+ {
+ var created = await service.CreateAsync(
+ requestContext!.TenantId,
+ requestContext.ActorId,
+ request,
+ cancellationToken).ConfigureAwait(false);
+
+ var location = $"/api/v2/scripts/{created.Id}";
+ return Results.Created(location, created);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return MapServiceError(ex);
+ }
+ })
+ .WithName("CreateScript")
+ .WithSummary("Create new script")
+ .WithDescription("Creates a new script in the registry with initial content and metadata.")
+ .RequireAuthorization(PlatformPolicies.ScriptWrite);
+
+ // GET /api/v2/scripts/{id} - Get script with content
+ scripts.MapGet("/{id}", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ TimeProvider timeProvider,
+ string id,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ var item = await service.GetByIdAsync(
+ requestContext!.TenantId,
+ id,
+ cancellationToken).ConfigureAwait(false);
+
+ if (item is null)
+ {
+ return Results.NotFound(new { error = "script_not_found", id });
+ }
+
+ return Results.Ok(new PlatformItemResponse(
+ requestContext.TenantId,
+ requestContext.ActorId,
+ timeProvider.GetUtcNow(),
+ Cached: false,
+ CacheTtlSeconds: 0,
+ item));
+ })
+ .WithName("GetScript")
+ .WithSummary("Get script by id")
+ .WithDescription("Retrieves a script including its full content.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ // PUT /api/v2/scripts/{id} - Update script
+ scripts.MapPut("/{id}", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ TimeProvider timeProvider,
+ string id,
+ [FromBody] UpdateScriptApiRequest request,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ try
+ {
+ var updated = await service.UpdateAsync(
+ requestContext!.TenantId,
+ requestContext.ActorId,
+ id,
+ request,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(new PlatformItemResponse(
+ requestContext.TenantId,
+ requestContext.ActorId,
+ timeProvider.GetUtcNow(),
+ Cached: false,
+ CacheTtlSeconds: 0,
+ updated));
+ }
+ catch (InvalidOperationException ex)
+ {
+ return MapServiceError(ex, id);
+ }
+ })
+ .WithName("UpdateScript")
+ .WithSummary("Update script")
+ .WithDescription("Updates an existing script. Content changes create a new version automatically.")
+ .RequireAuthorization(PlatformPolicies.ScriptWrite);
+
+ // DELETE /api/v2/scripts/{id} - Delete script
+ scripts.MapDelete("/{id}", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ string id,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ var deleted = await service.DeleteAsync(
+ requestContext!.TenantId,
+ id,
+ cancellationToken).ConfigureAwait(false);
+
+ if (!deleted)
+ {
+ return Results.NotFound(new { error = "script_not_found", id });
+ }
+
+ return Results.NoContent();
+ })
+ .WithName("DeleteScript")
+ .WithSummary("Delete script")
+ .WithDescription("Deletes a script and all its version history.")
+ .RequireAuthorization(PlatformPolicies.ScriptWrite);
+
+ // GET /api/v2/scripts/{id}/versions - Version history
+ scripts.MapGet("/{id}/versions", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ TimeProvider timeProvider,
+ string id,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ try
+ {
+ var versions = await service.GetVersionsAsync(
+ requestContext!.TenantId,
+ id,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(new PlatformListResponse(
+ requestContext.TenantId,
+ requestContext.ActorId,
+ timeProvider.GetUtcNow(),
+ Cached: false,
+ CacheTtlSeconds: 0,
+ versions,
+ versions.Count));
+ }
+ catch (InvalidOperationException ex)
+ {
+ return MapServiceError(ex, id);
+ }
+ })
+ .WithName("ListScriptVersions")
+ .WithSummary("List script version history")
+ .WithDescription("Returns the full version history for a script, newest first.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ // POST /api/v2/scripts/validate - Validate without saving
+ scripts.MapPost("/validate", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ [FromBody] ValidateScriptApiRequest request,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Content))
+ {
+ return Results.BadRequest(new { error = "script_content_required" });
+ }
+
+ var result = await service.ValidateAsync(
+ request,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(result);
+ })
+ .WithName("ValidateScript")
+ .WithSummary("Validate script without saving")
+ .WithDescription("Validates script syntax and returns diagnostics. Used by the editor compile button.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ // GET /api/v2/scripts/{id}/versions/{version}/content - Get version content for diff
+ scripts.MapGet("/{id}/versions/{version:int}/content", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ TimeProvider timeProvider,
+ string id,
+ int version,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ var item = await service.GetVersionContentAsync(
+ requestContext!.TenantId,
+ id,
+ version,
+ cancellationToken).ConfigureAwait(false);
+
+ if (item is null)
+ {
+ return Results.NotFound(new { error = "script_not_found", id });
+ }
+
+ return Results.Ok(new PlatformItemResponse(
+ requestContext.TenantId,
+ requestContext.ActorId,
+ timeProvider.GetUtcNow(),
+ Cached: false,
+ CacheTtlSeconds: 0,
+ item));
+ })
+ .WithName("GetScriptVersionContent")
+ .WithSummary("Get script version content")
+ .WithDescription("Retrieves the content of a specific script version for diff comparison.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ // POST /api/v2/scripts/{id}/check-compatibility - Check deployment compatibility
+ scripts.MapPost("/{id}/check-compatibility", async Task(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ IScriptService service,
+ string id,
+ [FromBody] CheckCompatibilityRequest request,
+ CancellationToken cancellationToken) =>
+ {
+ if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
+ {
+ return failure!;
+ }
+
+ try
+ {
+ var result = await service.CheckCompatibilityAsync(
+ requestContext!.TenantId,
+ id,
+ request,
+ cancellationToken).ConfigureAwait(false);
+
+ return Results.Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return MapServiceError(ex, id);
+ }
+ })
+ .WithName("CheckScriptCompatibility")
+ .WithSummary("Check script deployment compatibility")
+ .WithDescription("Checks if a script is compatible with a given deployment target type.")
+ .RequireAuthorization(PlatformPolicies.ScriptRead);
+
+ return app;
+ }
+
+ private static int NormalizeLimit(int? value)
+ {
+ return value switch
+ {
+ null => DefaultLimit,
+ < 1 => 1,
+ > MaxLimit => MaxLimit,
+ _ => value.Value
+ };
+ }
+
+ private static int NormalizeOffset(int? value) => value is null or < 0 ? 0 : value.Value;
+
+ private static IResult MapServiceError(InvalidOperationException exception, string? id = null)
+ {
+ return exception.Message switch
+ {
+ "script_not_found" => Results.NotFound(new { error = "script_not_found", id }),
+ "script_name_required" => Results.BadRequest(new { error = "script_name_required" }),
+ "script_content_required" => Results.BadRequest(new { error = "script_content_required" }),
+ "request_required" => Results.BadRequest(new { error = "request_required" }),
+ "script_validation_failed" => Results.BadRequest(new { error = "script_validation_failed", detail = exception.Message }),
+ _ => Results.BadRequest(new { error = exception.Message })
+ };
+ }
+
+ private static bool TryResolveContext(
+ HttpContext context,
+ PlatformRequestContextResolver resolver,
+ out PlatformRequestContext? requestContext,
+ out IResult? failure)
+ {
+ if (resolver.TryResolve(context, out requestContext, out var error))
+ {
+ failure = null;
+ return true;
+ }
+
+ failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
+ return false;
+ }
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs
index 2070d47e7..f1aba7787 100644
--- a/src/Platform/StellaOps.Platform.WebService/Program.cs
+++ b/src/Platform/StellaOps.Platform.WebService/Program.cs
@@ -157,6 +157,8 @@ builder.Services.AddAuthorization(options =>
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationManage, PlatformScopes.FederationManage);
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderRead, PlatformScopes.IdentityProviderRead);
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderAdmin, PlatformScopes.IdentityProviderAdmin);
+ options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptRead, PlatformScopes.ScriptRead);
+ options.AddStellaOpsScopePolicy(PlatformPolicies.ScriptWrite, PlatformScopes.ScriptWrite);
});
builder.Services.AddSingleton();
@@ -288,6 +290,9 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Script registry services (multi-language script editor)
+builder.Services.AddSingleton();
+
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "platform",
@@ -358,6 +363,7 @@ app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();
app.MapMigrationAdminEndpoints();
app.MapRegistrySearchEndpoints();
+app.MapScriptEndpoints();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
.WithTags("Health")
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs b/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs
new file mode 100644
index 000000000..c39bd050b
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/IScriptService.cs
@@ -0,0 +1,69 @@
+using StellaOps.Platform.WebService.Contracts;
+
+namespace StellaOps.Platform.WebService.Services;
+
+///
+/// Service interface for script CRUD, versioning, and validation.
+///
+public interface IScriptService
+{
+ Task> SearchAsync(
+ string tenantId,
+ ScriptLanguageDto? language,
+ ScriptVisibilityDto? visibility,
+ string? search,
+ int limit,
+ int offset,
+ CancellationToken cancellationToken = default);
+
+ Task CountAsync(
+ string tenantId,
+ ScriptLanguageDto? language,
+ ScriptVisibilityDto? visibility,
+ string? search,
+ CancellationToken cancellationToken = default);
+
+ Task GetByIdAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default);
+
+ Task CreateAsync(
+ string tenantId,
+ string actorId,
+ CreateScriptApiRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ string tenantId,
+ string actorId,
+ string scriptId,
+ UpdateScriptApiRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default);
+
+ Task> GetVersionsAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default);
+
+ Task ValidateAsync(
+ ValidateScriptApiRequest request,
+ CancellationToken cancellationToken = default);
+
+ Task GetVersionContentAsync(
+ string tenantId,
+ string scriptId,
+ int version,
+ CancellationToken cancellationToken = default);
+
+ Task CheckCompatibilityAsync(
+ string tenantId,
+ string scriptId,
+ CheckCompatibilityRequest request,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs
new file mode 100644
index 000000000..87743410e
--- /dev/null
+++ b/src/Platform/StellaOps.Platform.WebService/Services/InMemoryScriptService.cs
@@ -0,0 +1,843 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
+using StellaOps.Platform.WebService.Contracts;
+
+namespace StellaOps.Platform.WebService.Services;
+
+///
+/// In-memory implementation of for development and testing.
+///
+public sealed class InMemoryScriptService : IScriptService
+{
+ private readonly TimeProvider _timeProvider;
+ private readonly ConcurrentDictionary _states = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// The 16 known STELLA_* system variable suffixes.
+ ///
+ private static readonly HashSet KnownStellaVars = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "RELEASE_ID", "RELEASE_NAME", "RELEASE_VERSION", "RELEASE_TYPE",
+ "COMPONENT_NAME", "TARGET_ENVIRONMENT", "TARGET_REGION", "DEPLOYMENT_STRATEGY",
+ "TENANT_ID", "ACTOR_ID", "TIMESTAMP", "TIMEOUT_SECONDS",
+ "WORKING_DIR", "AGENT_TYPE", "COMPOSE_LOCK", "VERSION_STICKER"
+ };
+
+ private static readonly Regex HardcodedIpRegex = new(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", RegexOptions.Compiled);
+ private static readonly Regex TodoFixmeRegex = new(@"\b(TODO|FIXME)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private static readonly Regex DangerousRmRegex = new(@"rm\s+-rf\s+/(?:\s|$|;)", RegexOptions.Compiled);
+ private static readonly Regex EmptyCatchRegex = new(@"catch\s*(\([^)]*\))?\s*\{(\s|\n)*\}", RegexOptions.Compiled);
+
+ // Variable reference patterns per language family
+ private static readonly Regex BashVarRefRegex = new(@"\$\{(STELLA_\w+)\}", RegexOptions.Compiled);
+ private static readonly Regex PowerShellVarRefRegex = new(@"\$env:(STELLA_\w+)", RegexOptions.Compiled);
+ private static readonly Regex CSharpEnvVarRefRegex = new(@"Environment\.GetEnvironmentVariable\(\s*""(STELLA_\w+)""\s*\)", RegexOptions.Compiled);
+
+ public InMemoryScriptService(TimeProvider timeProvider)
+ {
+ _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
+ }
+
+ public Task> SearchAsync(
+ string tenantId,
+ ScriptLanguageDto? language,
+ ScriptVisibilityDto? visibility,
+ string? search,
+ int limit,
+ int offset,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ var query = state.Scripts.Values.AsEnumerable();
+
+ if (language is not null)
+ {
+ query = query.Where(s => s.Language == language.Value);
+ }
+
+ if (visibility is not null)
+ {
+ query = query.Where(s => s.Visibility == visibility.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ var term = search.Trim();
+ query = query.Where(s =>
+ s.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
+ (s.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
+ s.Tags.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ var list = query
+ .OrderByDescending(s => s.UpdatedAt ?? s.CreatedAt)
+ .ThenBy(s => s.Name, StringComparer.Ordinal)
+ .Skip(Math.Max(offset, 0))
+ .Take(Math.Max(limit, 1))
+ .Select(ToSummary)
+ .ToArray();
+
+ return Task.FromResult>(list);
+ }
+ }
+
+ public Task CountAsync(
+ string tenantId,
+ ScriptLanguageDto? language,
+ ScriptVisibilityDto? visibility,
+ string? search,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ var query = state.Scripts.Values.AsEnumerable();
+
+ if (language is not null)
+ {
+ query = query.Where(s => s.Language == language.Value);
+ }
+
+ if (visibility is not null)
+ {
+ query = query.Where(s => s.Visibility == visibility.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(search))
+ {
+ var term = search.Trim();
+ query = query.Where(s =>
+ s.Name.Contains(term, StringComparison.OrdinalIgnoreCase) ||
+ (s.Description?.Contains(term, StringComparison.OrdinalIgnoreCase) ?? false) ||
+ s.Tags.Any(t => t.Contains(term, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ return Task.FromResult(query.Count());
+ }
+ }
+
+ public Task GetByIdAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ return Task.FromResult(
+ state.Scripts.TryGetValue(scriptId, out var script)
+ ? script
+ : null);
+ }
+ }
+
+ public Task CreateAsync(
+ string tenantId,
+ string actorId,
+ CreateScriptApiRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (request is null)
+ {
+ throw new InvalidOperationException("request_required");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Name))
+ {
+ throw new InvalidOperationException("script_name_required");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Content))
+ {
+ throw new InvalidOperationException("script_content_required");
+ }
+
+ var state = GetState(tenantId);
+ var now = _timeProvider.GetUtcNow();
+ var id = Guid.NewGuid().ToString("N")[..12];
+ var dependencies = request.Dependencies?.Select(d => new ScriptDependencyDto(d.Name, d.Version, d.Source, d.IsDevelopment)).ToArray()
+ ?? [];
+ var tags = request.Tags?.ToArray() ?? [];
+ var variables = request.Variables?.ToArray() ?? [];
+ var contentHash = ComputeContentHash(request.Content, dependencies);
+
+ var script = new ScriptDetail(
+ Id: id,
+ Name: request.Name.Trim(),
+ Description: request.Description?.Trim(),
+ Language: request.Language,
+ Content: request.Content,
+ EntryPoint: request.EntryPoint?.Trim(),
+ Visibility: request.Visibility,
+ Version: 1,
+ Dependencies: dependencies,
+ Tags: tags,
+ Variables: variables,
+ OwnerId: actorId,
+ TeamId: null,
+ CreatedAt: now,
+ UpdatedAt: null,
+ ContentHash: contentHash,
+ IsSample: false,
+ SampleCategory: null);
+
+ lock (state.Sync)
+ {
+ state.Scripts[id] = script;
+
+ // Store initial version
+ var version = new ScriptVersionDto(
+ ScriptId: id,
+ Version: 1,
+ ContentHash: contentHash,
+ Dependencies: dependencies,
+ CreatedAt: now,
+ CreatedBy: actorId,
+ ChangeNote: "Initial version");
+
+ if (!state.Versions.TryGetValue(id, out var versions))
+ {
+ versions = [];
+ state.Versions[id] = versions;
+ }
+
+ versions.Add(version);
+
+ // Store version content
+ state.VersionContents[id] = new Dictionary { [1] = request.Content };
+ }
+
+ return Task.FromResult(script);
+ }
+
+ public Task UpdateAsync(
+ string tenantId,
+ string actorId,
+ string scriptId,
+ UpdateScriptApiRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ if (!state.Scripts.TryGetValue(scriptId, out var existing))
+ {
+ throw new InvalidOperationException("script_not_found");
+ }
+
+ var now = _timeProvider.GetUtcNow();
+ var contentChanged = request.Content is not null || request.Dependencies is not null;
+
+ var newContent = request.Content ?? existing.Content;
+ var newDependencies = request.Dependencies?.Select(d => new ScriptDependencyDto(d.Name, d.Version, d.Source, d.IsDevelopment)).ToArray()
+ ?? existing.Dependencies.ToArray();
+ var newVersion = contentChanged ? existing.Version + 1 : existing.Version;
+ var contentHash = contentChanged
+ ? ComputeContentHash(newContent, newDependencies)
+ : existing.ContentHash;
+ var newVariables = request.Variables?.ToArray() ?? existing.Variables.ToArray();
+
+ var updated = new ScriptDetail(
+ Id: existing.Id,
+ Name: request.Name?.Trim() ?? existing.Name,
+ Description: request.Description?.Trim() ?? existing.Description,
+ Language: existing.Language,
+ Content: newContent,
+ EntryPoint: request.EntryPoint?.Trim() ?? existing.EntryPoint,
+ Visibility: request.Visibility ?? existing.Visibility,
+ Version: newVersion,
+ Dependencies: newDependencies,
+ Tags: request.Tags?.ToArray() ?? existing.Tags.ToArray(),
+ Variables: newVariables,
+ OwnerId: existing.OwnerId,
+ TeamId: existing.TeamId,
+ CreatedAt: existing.CreatedAt,
+ UpdatedAt: now,
+ ContentHash: contentHash,
+ IsSample: existing.IsSample,
+ SampleCategory: existing.SampleCategory);
+
+ state.Scripts[scriptId] = updated;
+
+ if (contentChanged)
+ {
+ if (!state.Versions.TryGetValue(scriptId, out var versions))
+ {
+ versions = [];
+ state.Versions[scriptId] = versions;
+ }
+
+ versions.Add(new ScriptVersionDto(
+ ScriptId: scriptId,
+ Version: newVersion,
+ ContentHash: contentHash,
+ Dependencies: newDependencies,
+ CreatedAt: now,
+ CreatedBy: actorId,
+ ChangeNote: request.ChangeNote));
+
+ // Store version content
+ if (!state.VersionContents.TryGetValue(scriptId, out var versionContents))
+ {
+ versionContents = new Dictionary();
+ state.VersionContents[scriptId] = versionContents;
+ }
+
+ versionContents[newVersion] = newContent;
+ }
+
+ return Task.FromResult(updated);
+ }
+ }
+
+ public Task DeleteAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ var removed = state.Scripts.Remove(scriptId);
+ state.Versions.Remove(scriptId);
+ state.VersionContents.Remove(scriptId);
+ return Task.FromResult(removed);
+ }
+ }
+
+ public Task> GetVersionsAsync(
+ string tenantId,
+ string scriptId,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ if (!state.Scripts.ContainsKey(scriptId))
+ {
+ throw new InvalidOperationException("script_not_found");
+ }
+
+ if (!state.Versions.TryGetValue(scriptId, out var versions))
+ {
+ return Task.FromResult>([]);
+ }
+
+ var result = versions
+ .OrderByDescending(v => v.Version)
+ .ToArray();
+
+ return Task.FromResult>(result);
+ }
+ }
+
+ public Task GetVersionContentAsync(
+ string tenantId,
+ string scriptId,
+ int version,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ lock (state.Sync)
+ {
+ if (!state.Scripts.ContainsKey(scriptId))
+ {
+ throw new InvalidOperationException("script_not_found");
+ }
+
+ // Look up the version metadata
+ if (!state.Versions.TryGetValue(scriptId, out var versions))
+ {
+ return Task.FromResult(null);
+ }
+
+ var versionMeta = versions.FirstOrDefault(v => v.Version == version);
+ if (versionMeta is null)
+ {
+ return Task.FromResult(null);
+ }
+
+ // Look up the version content
+ if (!state.VersionContents.TryGetValue(scriptId, out var versionContents) ||
+ !versionContents.TryGetValue(version, out var content))
+ {
+ return Task.FromResult(null);
+ }
+
+ var detail = new ScriptVersionDetailDto(
+ ScriptId: versionMeta.ScriptId,
+ Version: versionMeta.Version,
+ ContentHash: versionMeta.ContentHash,
+ Content: content,
+ CreatedAt: versionMeta.CreatedAt,
+ CreatedBy: versionMeta.CreatedBy,
+ ChangeNote: versionMeta.ChangeNote);
+
+ return Task.FromResult(detail);
+ }
+ }
+
+ public Task CheckCompatibilityAsync(
+ string tenantId,
+ string scriptId,
+ CheckCompatibilityRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var state = GetState(tenantId);
+
+ ScriptDetail script;
+ lock (state.Sync)
+ {
+ if (!state.Scripts.TryGetValue(scriptId, out script!))
+ {
+ throw new InvalidOperationException("script_not_found");
+ }
+ }
+
+ var issues = new List();
+ var targetType = request.TargetType?.ToLowerInvariant() ?? "";
+
+ // Language-target compatibility matrix
+ switch (script.Language)
+ {
+ case ScriptLanguageDto.Bash:
+ switch (targetType)
+ {
+ case "docker_host":
+ case "compose_host":
+ case "nomad_job":
+ // OK
+ break;
+ case "ecs_service":
+ issues.Add(new CompatibilityIssue(
+ "runtime",
+ "Warning",
+ "Bash scripts on ECS require ECS Exec with proper IAM permissions"));
+ break;
+ }
+ break;
+
+ // CSharp, Python, TypeScript, Go, Java — all OK with all targets
+ }
+
+ // Variable resolution: required non-secret variables vs. TargetMetadata
+ foreach (var variable in script.Variables)
+ {
+ if (!variable.IsRequired || variable.IsSecret)
+ {
+ continue;
+ }
+
+ if (variable.DefaultValue is not null)
+ {
+ continue; // Has a default, not strictly missing
+ }
+
+ if (request.TargetMetadata is null ||
+ !request.TargetMetadata.ContainsKey(variable.Name))
+ {
+ issues.Add(new CompatibilityIssue(
+ "variable",
+ "Error",
+ $"Required variable '{variable.Name}' is not provided in target metadata"));
+ }
+ }
+
+ // Secret availability: secret variables vs. AvailableSecrets
+ foreach (var variable in script.Variables)
+ {
+ if (!variable.IsSecret)
+ {
+ continue;
+ }
+
+ var availableSecrets = request.AvailableSecrets ?? [];
+ if (!availableSecrets.Any(s => s.Equals(variable.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ issues.Add(new CompatibilityIssue(
+ "secret",
+ "Warning",
+ $"Secret variable '{variable.Name}' is not available in the target environment"));
+ }
+ }
+
+ // Runtime notes
+ switch (targetType)
+ {
+ case "ecs_service":
+ issues.Add(new CompatibilityIssue(
+ "runtime",
+ "Info",
+ "ECS Exec requires ssmmessages:* IAM permissions"));
+ break;
+ case "nomad_job":
+ issues.Add(new CompatibilityIssue(
+ "runtime",
+ "Info",
+ "Script runs via Nomad exec in task allocation"));
+ break;
+ }
+
+ var isCompatible = !issues.Any(i => i.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase));
+
+ return Task.FromResult(new CompatibilityResultDto(isCompatible, issues));
+ }
+
+ public Task ValidateAsync(
+ ValidateScriptApiRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var errors = new List();
+ var diagnostics = new List();
+
+ if (string.IsNullOrWhiteSpace(request.Content))
+ {
+ errors.Add("Script content cannot be empty");
+ }
+ else
+ {
+ var lines = request.Content.Split('\n');
+
+ // Language-specific validation
+ switch (request.Language)
+ {
+ case ScriptLanguageDto.CSharp:
+ case ScriptLanguageDto.TypeScript:
+ case ScriptLanguageDto.Java:
+ ValidateBracedLanguage(request.Content, lines, errors, diagnostics);
+ break;
+ case ScriptLanguageDto.Python:
+ ValidatePython(lines, diagnostics);
+ break;
+ case ScriptLanguageDto.Bash:
+ ValidateBash(request.Content, lines, errors, diagnostics);
+ break;
+ }
+
+ // CSharp-specific: empty catch blocks
+ if (request.Language == ScriptLanguageDto.CSharp)
+ {
+ ValidateCSharpEmptyCatch(request.Content, lines, diagnostics);
+ }
+
+ // Variable reference validation (all languages)
+ ValidateVariableReferences(request.Content, request.Language, request.DeclaredVariables, diagnostics);
+
+ // General checks (all languages)
+ ValidateGeneral(lines, diagnostics);
+ }
+
+ var result = new ScriptValidationResultDto(
+ IsValid: errors.Count == 0,
+ Errors: errors,
+ Diagnostics: diagnostics);
+
+ return Task.FromResult(result);
+ }
+
+ private static void ValidateBracedLanguage(string content, string[] lines, List errors, List diagnostics)
+ {
+ // Walk lines tracking brace balance, report the line where imbalance starts
+ var balance = 0;
+ int? imbalanceStartLine = null;
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ foreach (var c in lines[i])
+ {
+ if (c == '{')
+ {
+ if (balance == 0 && imbalanceStartLine is null)
+ {
+ imbalanceStartLine = i + 1; // 1-based
+ }
+
+ balance++;
+ }
+ else if (c == '}')
+ {
+ balance--;
+ if (balance < 0 && imbalanceStartLine is null)
+ {
+ imbalanceStartLine = i + 1;
+ }
+ }
+ }
+ }
+
+ if (balance != 0)
+ {
+ var openBraces = content.Count(c => c == '{');
+ var closeBraces = content.Count(c => c == '}');
+ var reportLine = imbalanceStartLine ?? 1;
+
+ errors.Add($"Unbalanced braces: {openBraces} open, {closeBraces} close");
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Error,
+ Message: $"Unbalanced braces: {openBraces} open, {closeBraces} close",
+ Line: reportLine,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "structure"));
+ }
+ }
+
+ private static void ValidatePython(string[] lines, List diagnostics)
+ {
+ var usesTabs = lines.Any(l => l.StartsWith('\t'));
+ var usesSpaces = lines.Any(l => l.StartsWith(" "));
+
+ if (usesTabs && usesSpaces)
+ {
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: "Mixed tabs and spaces for indentation",
+ Line: 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "style"));
+ }
+ }
+
+ private static void ValidateBash(string content, string[] lines, List errors, List diagnostics)
+ {
+ // Warn if content doesn't contain set -euo pipefail
+ if (!content.Contains("set -euo pipefail"))
+ {
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: "Script does not contain 'set -euo pipefail' for safe error handling",
+ Line: 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "safety"));
+ }
+
+ // Error on rm -rf /
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (DangerousRmRegex.IsMatch(lines[i]))
+ {
+ errors.Add($"Dangerous 'rm -rf /' detected at line {i + 1}");
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Error,
+ Message: "Dangerous 'rm -rf /' command detected",
+ Line: i + 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "safety"));
+ }
+ }
+ }
+
+ private static void ValidateCSharpEmptyCatch(string content, string[] lines, List diagnostics)
+ {
+ var matches = EmptyCatchRegex.Matches(content);
+ foreach (Match match in matches)
+ {
+ // Find the line number of this match
+ var precedingContent = content[..match.Index];
+ var lineNumber = precedingContent.Count(c => c == '\n') + 1;
+
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: "Empty catch block detected — exceptions should be logged or handled",
+ Line: lineNumber,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "safety"));
+ }
+ }
+
+ private static void ValidateVariableReferences(
+ string content,
+ ScriptLanguageDto language,
+ IReadOnlyList? declaredVariables,
+ List diagnostics)
+ {
+ var referencedVars = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // Collect variable references based on language patterns
+ // Bash: ${STELLA_*}
+ foreach (Match match in BashVarRefRegex.Matches(content))
+ {
+ referencedVars.Add(match.Groups[1].Value);
+ }
+
+ // PowerShell: $env:STELLA_*
+ foreach (Match match in PowerShellVarRefRegex.Matches(content))
+ {
+ referencedVars.Add(match.Groups[1].Value);
+ }
+
+ // C#: Environment.GetEnvironmentVariable("STELLA_*")
+ foreach (Match match in CSharpEnvVarRefRegex.Matches(content))
+ {
+ referencedVars.Add(match.Groups[1].Value);
+ }
+
+ if (referencedVars.Count == 0)
+ {
+ return;
+ }
+
+ var declaredNames = new HashSet(
+ (declaredVariables ?? []).Select(v => v.Name),
+ StringComparer.OrdinalIgnoreCase);
+
+ foreach (var varName in referencedVars)
+ {
+ // Strip STELLA_ prefix to check against known system vars
+ var suffix = varName.StartsWith("STELLA_", StringComparison.OrdinalIgnoreCase)
+ ? varName["STELLA_".Length..]
+ : varName;
+
+ // Known system var or declared var — skip
+ if (KnownStellaVars.Contains(suffix))
+ {
+ continue;
+ }
+
+ // Check against declared variables (match full name including STELLA_ prefix)
+ if (declaredNames.Contains(varName))
+ {
+ continue;
+ }
+
+ // Find the line where this reference appears
+ var lines = content.Split('\n');
+ var lineNumber = 1;
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (lines[i].Contains(varName, StringComparison.OrdinalIgnoreCase))
+ {
+ lineNumber = i + 1;
+ break;
+ }
+ }
+
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: $"Unknown STELLA_* variable reference: '{varName}' is not a known system variable or declared variable",
+ Line: lineNumber,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "variable"));
+ }
+ }
+
+ private static void ValidateGeneral(string[] lines, List diagnostics)
+ {
+ // Warn on hardcoded IPs
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (HardcodedIpRegex.IsMatch(lines[i]))
+ {
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: "Hardcoded IP address detected — consider using configuration or DNS",
+ Line: i + 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "safety"));
+ }
+ }
+
+ // Info on TODO/FIXME comments
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (TodoFixmeRegex.IsMatch(lines[i]))
+ {
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Info,
+ Message: "TODO/FIXME comment found",
+ Line: i + 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "style"));
+ }
+ }
+
+ // Warn if content has >500 lines
+ if (lines.Length > 500)
+ {
+ diagnostics.Add(new ScriptDiagnosticDto(
+ Severity: ScriptDiagnosticSeverityDto.Warning,
+ Message: $"Script has {lines.Length} lines — consider breaking it into smaller scripts",
+ Line: 1,
+ Column: 1,
+ EndLine: null,
+ EndColumn: null,
+ Category: "structure"));
+ }
+ }
+
+ private static string ComputeContentHash(string content, IReadOnlyList dependencies)
+ {
+ var combined = content;
+ foreach (var dep in dependencies.OrderBy(d => d.Name, StringComparer.Ordinal))
+ {
+ combined += $"|{dep.Name}:{dep.Version}";
+ }
+
+ var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ private static ScriptSummary ToSummary(ScriptDetail detail) => new(
+ Id: detail.Id,
+ Name: detail.Name,
+ Description: detail.Description,
+ Language: detail.Language,
+ Visibility: detail.Visibility,
+ Version: detail.Version,
+ Tags: detail.Tags,
+ Variables: detail.Variables,
+ OwnerId: detail.OwnerId,
+ TeamId: detail.TeamId,
+ CreatedAt: detail.CreatedAt,
+ UpdatedAt: detail.UpdatedAt,
+ ContentHash: detail.ContentHash);
+
+ private TenantState GetState(string tenantId) =>
+ _states.GetOrAdd(tenantId, _ => new TenantState());
+
+ private sealed class TenantState
+ {
+ public readonly object Sync = new();
+ public readonly Dictionary Scripts = new(StringComparer.Ordinal);
+ public readonly Dictionary> Versions = new(StringComparer.Ordinal);
+ public readonly Dictionary> VersionContents = new(StringComparer.Ordinal);
+ }
+}