docs(adminui): cert-store actions implementation plan + tasks
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
# AdminUI Certificate Store Actions — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (here, subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add FleetAdmin-gated trust / untrust / delete actions to the read-only
|
||||
`/certificates` AdminUI page, backed by an in-process filesystem
|
||||
`CertificateStoreManager`, honored live by the running cert validator.
|
||||
|
||||
**Architecture:** New `CertificateStoreManager` (pure BCL filesystem, by-thumbprint
|
||||
moves/deletes on the PKI directory stores) injected into `Certificates.razor`,
|
||||
which gains per-row action buttons (`<AuthorizeView Policy="FleetAdmin">`) with
|
||||
inline Blazor confirmation. No new endpoint (avoids the Traefik self-dial trap),
|
||||
no SDK store dependency, no EF/Commons/proto change.
|
||||
|
||||
**Tech Stack:** .NET 10, Blazor Server, BCL `X509CertificateLoader` /
|
||||
`CertificateRequest`, xUnit + Shouldly. NO bUnit.
|
||||
|
||||
**Design:** `docs/plans/2026-06-18-adminui-cert-actions-design.md` (committed `f150460b`).
|
||||
|
||||
**Branch:** `feat/adminui-cert-actions` off master `8480e301`.
|
||||
|
||||
**Hard rules:** stage by explicit path (never `git add .`); never stage
|
||||
`sql_login.txt`, `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`, `pending.md`,
|
||||
`current.md`, `stillpending.md`, `docker-dev/docker-compose.yml`; never echo/commit
|
||||
secrets; no force-push; no `--no-verify`; NO EF migration; NO Commons wire/proto
|
||||
change; NO bUnit; `dangerouslyDisableSandbox: true` for all build/test/rig commands.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `CertificateStoreManager` + tests (the load-bearing logic)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (Task 2 depends on the type)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs`
|
||||
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs`
|
||||
|
||||
**Context:** This is greenfield — no cert-management code exists. A PKI directory
|
||||
store is `{PkiStoreRoot}/{kind}/certs/*.{der,cer,crt}`. `kind` ∈
|
||||
`{own, issuer, trusted, rejected}`; only `trusted` and `rejected` are mutated.
|
||||
The class mirrors how `Certificates.razor` already reads certs (BCL
|
||||
`X509CertificateLoader`, `Directory.EnumerateFiles`). Operations are synchronous,
|
||||
find files by **enumerate-and-match-thumbprint** (never build a path from the
|
||||
thumbprint), and return a `CertActionResult` instead of throwing.
|
||||
|
||||
**Step 1: Write the failing tests (TDD).**
|
||||
|
||||
`CertificateStoreManagerTests.cs` — xUnit + Shouldly. Use a per-test temp dir
|
||||
(`Path.Combine(Path.GetTempPath(), "otopcua-certtests-" + Guid.NewGuid())`),
|
||||
`IDisposable` cleanup. Helper to seed a cert:
|
||||
|
||||
```csharp
|
||||
private static string SeedCert(string storeCertsDir, string cn)
|
||||
{
|
||||
Directory.CreateDirectory(storeCertsDir);
|
||||
using var rsa = System.Security.Cryptography.RSA.Create(2048);
|
||||
var req = new System.Security.Cryptography.X509Certificates.CertificateRequest(
|
||||
$"CN={cn}", rsa, System.Security.Cryptography.HashAlgorithmName.SHA256,
|
||||
System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
var file = Path.Combine(storeCertsDir, $"{cn} [{cert.Thumbprint}].der");
|
||||
File.WriteAllBytes(file, cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert));
|
||||
return cert.Thumbprint!;
|
||||
}
|
||||
```
|
||||
|
||||
Tests (construct the manager via the `internal CertificateStoreManager(string pkiRoot)`
|
||||
test ctor — the test project already has `InternalsVisibleTo` for AdminUI; if not,
|
||||
add it in Task 1 to the AdminUI csproj):
|
||||
|
||||
- `Trust_MovesRejectedToTrusted`: seed in `rejected/certs`; `Trust(tp)` →
|
||||
`Success`; file gone from `rejected/certs`, present in `trusted/certs`
|
||||
(re-enumerate + match thumbprint to assert).
|
||||
- `Untrust_MovesTrustedToRejected`: symmetric.
|
||||
- `Delete_Rejected_RemovesFile` and `Delete_Trusted_RemovesFile`.
|
||||
- `Trust_UnknownThumbprint_FailsNoChange`: `Trust("A0".PadRight(40,'B'))` (valid
|
||||
hex, not present) → `Success == false`, stores unchanged.
|
||||
- `Trust_PathTraversalThumbprint_Rejected`: `Trust("../../x")` → `Fail`, no file
|
||||
created/moved anywhere.
|
||||
- `Delete_DisallowedStore_Fails`: `Delete("own", tp)` → `Fail` (error mentions
|
||||
store), the `own` store untouched.
|
||||
- `Trust_AlreadyInDest_IdempotentSuccess`: seed the SAME cert bytes in both
|
||||
`rejected/certs` and `trusted/certs`; `Trust(tp)` → `Success`, source removed,
|
||||
dest still has it.
|
||||
|
||||
**Step 2: Run tests, verify they fail to compile / fail.**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CertificateStoreManagerTests"`
|
||||
Expected: FAIL (type does not exist yet).
|
||||
|
||||
**Step 3: Implement `CertificateStoreManager`.**
|
||||
|
||||
```csharp
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
|
||||
|
||||
/// <summary>Result of a certificate-store mutation; carries a friendly error instead of throwing.</summary>
|
||||
public sealed record CertActionResult(bool Success, string? Error)
|
||||
{
|
||||
public static CertActionResult Ok() => new(true, null);
|
||||
public static CertActionResult Fail(string error) => new(false, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trusts / untrusts / deletes peer certificates by moving or removing files in the
|
||||
/// OPC UA server's PKI directory stores. The running server's DirectoryStore re-enumerates
|
||||
/// <c>certs/</c> on each validation, so changes are honored live without a restart.
|
||||
/// </summary>
|
||||
public sealed class CertificateStoreManager
|
||||
{
|
||||
private static readonly string[] CertExtensions = [".der", ".cer", ".crt"];
|
||||
private static readonly HashSet<string> MutableStores =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
|
||||
|
||||
private readonly string _pkiRoot;
|
||||
|
||||
/// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
|
||||
public CertificateStoreManager(IConfiguration config)
|
||||
=> _pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
||||
|
||||
/// <summary>Test ctor — explicit PKI root.</summary>
|
||||
internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
/// <summary>Moves a rejected peer cert into the trusted store.</summary>
|
||||
public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint);
|
||||
|
||||
/// <summary>Moves a trusted peer cert back into the rejected store.</summary>
|
||||
public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint);
|
||||
|
||||
/// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
|
||||
public CertActionResult Delete(string store, string thumbprint)
|
||||
{
|
||||
if (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'");
|
||||
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
||||
try
|
||||
{
|
||||
var file = FindByThumbprint(CertsDir(store), thumbprint);
|
||||
if (file is null) return CertActionResult.Fail($"certificate not found in {store}");
|
||||
File.Delete(file);
|
||||
return CertActionResult.Ok();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return CertActionResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private CertActionResult Move(string fromSub, string toSub, string thumbprint)
|
||||
{
|
||||
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
||||
try
|
||||
{
|
||||
var src = FindByThumbprint(CertsDir(fromSub), thumbprint);
|
||||
if (src is null) return CertActionResult.Fail($"certificate not found in {fromSub}");
|
||||
|
||||
var destDir = CertsDir(toSub);
|
||||
Directory.CreateDirectory(destDir);
|
||||
|
||||
// Idempotent: if dest already holds this thumbprint, just drop the source.
|
||||
if (FindByThumbprint(destDir, thumbprint) is not null)
|
||||
{
|
||||
File.Delete(src);
|
||||
return CertActionResult.Ok();
|
||||
}
|
||||
|
||||
var dest = Path.Combine(destDir, Path.GetFileName(src));
|
||||
if (File.Exists(dest))
|
||||
dest = Path.Combine(destDir,
|
||||
$"{Path.GetFileNameWithoutExtension(src)}_{thumbprint}{Path.GetExtension(src)}");
|
||||
File.Move(src, dest);
|
||||
return CertActionResult.Ok();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return CertActionResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private string CertsDir(string sub) => Path.Combine(_pkiRoot, sub, "certs");
|
||||
|
||||
private static string? FindByThumbprint(string certsDir, string thumbprint)
|
||||
{
|
||||
if (!Directory.Exists(certsDir)) return null;
|
||||
foreach (var file in Directory.EnumerateFiles(certsDir))
|
||||
{
|
||||
if (!CertExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
|
||||
try
|
||||
{
|
||||
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
|
||||
if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
return file;
|
||||
}
|
||||
catch { /* ignore unreadable entries */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidThumbprint(string thumbprint) =>
|
||||
!string.IsNullOrEmpty(thumbprint)
|
||||
&& (thumbprint.Length == 40 || thumbprint.Length == 64)
|
||||
&& thumbprint.All(Uri.IsHexDigit);
|
||||
}
|
||||
```
|
||||
|
||||
If the AdminUI.Tests project lacks `InternalsVisibleTo` for the AdminUI assembly,
|
||||
add `<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.AdminUI.Tests" />` via an
|
||||
`<ItemGroup>` in the AdminUI `.csproj` (check first — many projects in this repo
|
||||
already have it).
|
||||
|
||||
**Step 4: Run tests, verify green.**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~CertificateStoreManagerTests"`
|
||||
Expected: PASS (all 9).
|
||||
|
||||
**Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs \
|
||||
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs
|
||||
# (+ the AdminUI .csproj only if you added InternalsVisibleTo)
|
||||
git commit -m "feat(adminui): CertificateStoreManager — by-thumbprint trust/untrust/delete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire actions into `Certificates.razor` + DI registration
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (depends on Task 1's type)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor`
|
||||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs`
|
||||
|
||||
**Context:** The page (read it fully first) renders four stores in `OnInitialized`
|
||||
via `LoadStore(label, path)` → `StoreView(Label, Path, List<X509Certificate2>)`.
|
||||
Add a store **kind** so the table knows which rows get actions; add an Actions
|
||||
column gated by `<AuthorizeView Policy="FleetAdmin">`; add inline confirmation
|
||||
(NO `window.confirm`); call the injected `CertificateStoreManager`.
|
||||
|
||||
**Step 1: Register the service in DI.**
|
||||
|
||||
In `EndpointRouteBuilderExtensions.AddAdminUI`, after the ScriptAnalysis
|
||||
registrations, add:
|
||||
|
||||
```csharp
|
||||
// Certificate-store actions (trust/untrust/delete) for the /certificates page.
|
||||
services.AddSingleton<Certificates.CertificateStoreManager>();
|
||||
```
|
||||
|
||||
**Step 2: Modify `Certificates.razor`.**
|
||||
|
||||
Add the inject + an enum, tag each `StoreView` with its kind, render an Actions
|
||||
column, and handle the confirm flow. Concrete changes:
|
||||
|
||||
- Header block: add `@inject Certificates.CertificateStoreManager CertManager`.
|
||||
- Replace the `StoreView` record with one carrying a kind:
|
||||
|
||||
```csharp
|
||||
private enum StoreKind { Own, Trusted, Issuer, Rejected }
|
||||
private sealed record StoreView(string Label, StoreKind Kind, string Path, List<X509Certificate2> Certificates);
|
||||
```
|
||||
|
||||
- Refactor `OnInitialized()` into `LoadAll()` and call it from `OnInitialized()`:
|
||||
|
||||
```csharp
|
||||
protected override void OnInitialized() => LoadAll();
|
||||
|
||||
private void LoadAll()
|
||||
{
|
||||
var pkiRoot = Config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
||||
_rows = new()
|
||||
{
|
||||
LoadStore("Own", StoreKind.Own, Path.Combine(pkiRoot, "own", "certs")),
|
||||
LoadStore("Trusted peers", StoreKind.Trusted, Path.Combine(pkiRoot, "trusted", "certs")),
|
||||
LoadStore("Trusted issuers", StoreKind.Issuer, Path.Combine(pkiRoot, "issuer", "certs")),
|
||||
LoadStore("Rejected", StoreKind.Rejected, Path.Combine(pkiRoot, "rejected", "certs")),
|
||||
};
|
||||
_pending = null;
|
||||
}
|
||||
```
|
||||
(update `LoadStore` signature to take `StoreKind kind` and pass it into `StoreView`.)
|
||||
|
||||
- Add fields + the action/confirm handlers in `@code`:
|
||||
|
||||
```csharp
|
||||
private (StoreKind Kind, string Thumbprint, string Subject, string Verb)? _pending;
|
||||
private string? _statusMsg;
|
||||
private bool _statusError;
|
||||
|
||||
private void RequestAction(StoreKind kind, X509Certificate2 cert, string verb)
|
||||
{
|
||||
_pending = (kind, cert.Thumbprint!, cert.Subject, verb);
|
||||
_statusMsg = null;
|
||||
}
|
||||
|
||||
private void CancelAction() => _pending = null;
|
||||
|
||||
private void ConfirmAction()
|
||||
{
|
||||
if (_pending is not { } p) return;
|
||||
var result = p.Verb switch
|
||||
{
|
||||
"trust" => CertManager.Trust(p.Thumbprint),
|
||||
"untrust" => CertManager.Untrust(p.Thumbprint),
|
||||
"delete" => CertManager.Delete(p.Kind == StoreKind.Trusted ? "trusted" : "rejected", p.Thumbprint),
|
||||
_ => CertActionResult.Fail("unknown action"),
|
||||
};
|
||||
_statusError = !result.Success;
|
||||
_statusMsg = result.Success
|
||||
? $"{char.ToUpper(p.Verb[0])}{p.Verb[1..]} of {p.Subject} succeeded."
|
||||
: $"{p.Verb} failed: {result.Error}";
|
||||
LoadAll(); // clears _pending
|
||||
}
|
||||
```
|
||||
|
||||
- In the table, add an `Actions` header + cell. Render the cell's buttons only for
|
||||
`Trusted`/`Rejected` and only inside `<AuthorizeView Policy="FleetAdmin">`:
|
||||
|
||||
```razor
|
||||
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
|
||||
{
|
||||
<AuthorizeView Policy="FleetAdmin">
|
||||
<td>
|
||||
@if (store.Kind == StoreKind.Rejected)
|
||||
{
|
||||
<button class="btn-sm" @onclick="() => RequestAction(store.Kind, c, \"trust\")">Trust</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn-sm" @onclick="() => RequestAction(store.Kind, c, \"untrust\")">Untrust</button>
|
||||
}
|
||||
<button class="btn-sm danger" @onclick="() => RequestAction(store.Kind, c, \"delete\")">Delete</button>
|
||||
</td>
|
||||
</AuthorizeView>
|
||||
}
|
||||
```
|
||||
|
||||
(Match the existing CSS classes used elsewhere in AdminUI — inspect a sibling page
|
||||
for the real button class names, e.g. `btn`, `btn-danger`; keep it minimal and
|
||||
consistent. The `Actions` `<th>` should also be inside an `AuthorizeView` or
|
||||
always-present — simplest: always render the `<th>Actions</th>` for trusted/rejected
|
||||
tables and an empty `<td>` for non-admins.)
|
||||
|
||||
- Add the inline confirm + status banner near the top of the `else` block (after
|
||||
the intro panel), e.g.:
|
||||
|
||||
```razor
|
||||
@if (_statusMsg is not null)
|
||||
{
|
||||
<div class="panel @(_statusError ? "error" : "notice")">@_statusMsg</div>
|
||||
}
|
||||
@if (_pending is { } p)
|
||||
{
|
||||
<div class="panel notice">
|
||||
Confirm <strong>@p.Verb</strong> of <span class="mono small">@p.Subject</span>?
|
||||
<button class="btn-sm" @onclick="ConfirmAction">Confirm</button>
|
||||
<button class="btn-sm" @onclick="CancelAction">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Build.**
|
||||
|
||||
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj`
|
||||
Expected: 0 errors. (Razor compiles; no unit test — bUnit is not used.)
|
||||
|
||||
**Step 4: Commit.**
|
||||
|
||||
```bash
|
||||
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor \
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
|
||||
git commit -m "feat(adminui): trust/untrust/delete actions on /certificates (FleetAdmin)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Docs + full build + AdminUI test suite + live `/run` verify + finish
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~4 min (+ live verify)
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/security.md` (one-paragraph note on the cert-store actions)
|
||||
|
||||
**Step 1: Doc note.** Add a short subsection to `docs/security.md` under the cert /
|
||||
PKI material: the `/certificates` page now offers FleetAdmin-gated Trust (rejected→
|
||||
trusted), Untrust (trusted→rejected), and Delete; changes are filesystem moves on
|
||||
the PKI directory stores and are honored live by the validator.
|
||||
|
||||
**Step 2: Full solution build.**
|
||||
|
||||
Run: `dotnet build ZB.MOM.WW.OtOpcUa.slnx`
|
||||
Expected: 0 errors.
|
||||
|
||||
**Step 3: AdminUI test suite (no regressions).**
|
||||
|
||||
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`
|
||||
Expected: all green (existing + 9 new).
|
||||
|
||||
**Step 4: Live `/run` verify (controller does this — login is disabled on the rig).**
|
||||
|
||||
- Pick ONE central container (e.g. `otopcua-dev-central-1-1`); rebuild it if the
|
||||
AdminUI changed (per the durable rig fact, `:9200` round-robins central-1/2 — to
|
||||
avoid old/new flicker, rebuild BOTH, or pin the browser to a single node).
|
||||
- Seed a self-signed DER into that node's `rejected/certs/` (generate locally,
|
||||
`docker cp`/`docker exec` into the container's PKI rejected dir; confirm the
|
||||
store path from the container's appsettings `OpcUa:PkiStoreRoot`).
|
||||
- Open `http://localhost:9200/certificates` (Chrome automation), confirm the seeded
|
||||
cert shows under **Rejected** with `[Trust] [Delete]`.
|
||||
- Click `[Trust]` → `[Confirm]`; confirm the success banner, the cert now under
|
||||
**Trusted peers**, gone from **Rejected** (and `docker exec ls` shows the file
|
||||
moved on disk).
|
||||
- Click `[Delete]` → `[Confirm]` on a cert; confirm removal.
|
||||
|
||||
**Step 5: Finish (merge to master + push).**
|
||||
|
||||
Use superpowers-extended-cc:finishing-a-development-branch. Per standing cadence:
|
||||
merge `feat/adminui-cert-actions` → master, push to origin, delete the branch.
|
||||
Stage by path; never stage the never-stage files.
|
||||
|
||||
```bash
|
||||
git add docs/security.md
|
||||
git commit -m "docs(security): note cert-store trust/untrust/delete actions"
|
||||
git checkout master && git merge --ff-only feat/adminui-cert-actions && git push origin master
|
||||
git branch -d feat/adminui-cert-actions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task dependency graph
|
||||
|
||||
```
|
||||
T1 (manager + tests) → T2 (razor + DI) → T3 (docs + build + tests + /run + finish)
|
||||
```
|
||||
|
||||
All serial — T2 needs T1's type; T3 verifies the whole.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-18-adminui-cert-actions.md",
|
||||
"designPath": "docs/plans/2026-06-18-adminui-cert-actions-design.md",
|
||||
"branch": "feat/adminui-cert-actions",
|
||||
"baseSha": "8480e301",
|
||||
"executionState": "PENDING",
|
||||
"tasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"subject": "Task 1: CertificateStoreManager + tests",
|
||||
"classification": "standard",
|
||||
"status": "pending",
|
||||
"parallelizableWith": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"subject": "Task 2: Certificates.razor actions + DI registration",
|
||||
"classification": "standard",
|
||||
"status": "pending",
|
||||
"blockedBy": [1],
|
||||
"parallelizableWith": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"subject": "Task 3: docs + full build + AdminUI tests + live /run + finish",
|
||||
"classification": "small",
|
||||
"status": "pending",
|
||||
"blockedBy": [2],
|
||||
"parallelizableWith": []
|
||||
}
|
||||
],
|
||||
"lastUpdated": "2026-06-18"
|
||||
}
|
||||
Reference in New Issue
Block a user