From cdcc7163fa45fb255f22424aeb1dbbc8b32cd7f8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 18:19:59 +0200 Subject: [PATCH] refactor(storage): scope exports by owner --- .../BackgroundWorkerTenantTests.cs | 6 ++- JobTrackerApi.Tests/CvExportRetentionTests.cs | 8 +++- .../JobApplicationsApplicationPackageTests.cs | 2 +- .../PublicCvControllerTests.cs | 3 +- .../Controllers/CvVariantController.cs | 2 +- .../Controllers/JobApplicationsController.cs | 4 +- .../Controllers/ProfileCvController.cs | 6 ++- .../Controllers/PublicCvController.cs | 2 +- JobTrackerApi/Services/AppPaths.cs | 14 ++++++ .../Services/DailyExportHostedService.cs | 7 +-- .../Services/PlaywrightCvPdfExporter.cs | 44 +++++++++++++------ docs/audits/verification-log.md | 1 + .../verification/sec-009-account-lifecycle.md | 36 +++++++++++++++ docs/work-programmes/decisions.md | 10 +++++ docs/work-programmes/master-progress.md | 2 +- docs/work-programmes/master-work-plan.md | 4 +- docs/work-programmes/session-handoff.md | 10 ++--- 17 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 docs/verification/sec-009-account-lifecycle.md diff --git a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs index 684eb12..664e7b8 100644 --- a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs +++ b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs @@ -138,9 +138,11 @@ public sealed class BackgroundWorkerTenantTests Mock.Of()); Assert.Equal(new BackgroundWorkerRunResult(true, 2, 2, 0), await worker.RunOnceAsync(default)); - var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json"); + var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json", SearchOption.AllDirectories); Assert.Equal(2, files.Length); Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal)); + Assert.Equal(2, files.Select(path => Directory.GetParent(path)!.Name).Distinct(StringComparer.Ordinal).Count()); + Assert.All(files, path => Assert.Matches("^[0-9a-f]{64}$", Directory.GetParent(path)!.Name)); var owners = new List(); foreach (var path in files) { @@ -159,7 +161,7 @@ public sealed class BackgroundWorkerTenantTests } owners.Sort(StringComparer.Ordinal); Assert.Equal(new[] { "user-1", "user-2" }, owners); - Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp")); + Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp", SearchOption.AllDirectories)); } finally { diff --git a/JobTrackerApi.Tests/CvExportRetentionTests.cs b/JobTrackerApi.Tests/CvExportRetentionTests.cs index 5888577..36bf7c1 100644 --- a/JobTrackerApi.Tests/CvExportRetentionTests.cs +++ b/JobTrackerApi.Tests/CvExportRetentionTests.cs @@ -15,11 +15,14 @@ public sealed class CvExportRetentionTests { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-export-retention-{Guid.NewGuid():N}"); var exportsRoot = Path.Combine(root, "CvExports"); - var old = Path.Combine(exportsRoot, "20260101"); - var keep = Path.Combine(exportsRoot, "20260731"); + var owner = AppPaths.GetOwnerStorageKey("user-1"); + var old = Path.Combine(exportsRoot, owner, "20260101"); + var keep = Path.Combine(exportsRoot, owner, "20260731"); + var legacyOld = Path.Combine(exportsRoot, "20260101"); var unrelated = Path.Combine(exportsRoot, "manual"); Directory.CreateDirectory(old); Directory.CreateDirectory(keep); + Directory.CreateDirectory(legacyOld); Directory.CreateDirectory(unrelated); try @@ -34,6 +37,7 @@ public sealed class CvExportRetentionTests Assert.False(Directory.Exists(old)); Assert.True(Directory.Exists(keep)); + Assert.False(Directory.Exists(legacyOld)); Assert.True(Directory.Exists(unrelated)); } finally diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index 769bc3d..73be0a3 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -597,7 +597,7 @@ public sealed class JobApplicationsApplicationPackageTests { public TailoredCvRenderResult? LastRenderResult { get; private set; } - public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + public Task ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { LastRenderResult = renderResult; return Task.FromResult(new CvPdfArtifact("preview.pdf", "/tmp/preview.pdf", new byte[] { 1, 2, 3 })); diff --git a/JobTrackerApi.Tests/PublicCvControllerTests.cs b/JobTrackerApi.Tests/PublicCvControllerTests.cs index 7b2d6f1..4e9a7ad 100644 --- a/JobTrackerApi.Tests/PublicCvControllerTests.cs +++ b/JobTrackerApi.Tests/PublicCvControllerTests.cs @@ -31,7 +31,7 @@ public sealed class PublicCvControllerTests variants.Setup(x => x.GetPublicOwnerAsync("public-slug", It.IsAny())).ReturnsAsync(user.Id); variants.Setup(x => x.RenderPublicAsync("public-slug", It.IsAny(), It.IsAny())) .ReturnsAsync((render, user.Id)); - pdf.Setup(x => x.ExportAsync(It.IsAny(), It.IsAny())) + pdf.Setup(x => x.ExportAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new CvPdfArtifact("ada-cv.pdf", "unused", [1, 2, 3])); var controller = new PublicCvController(TestHostFactory.CreateUserManager(user).Object, variants.Object, pdf.Object); @@ -42,6 +42,7 @@ public sealed class PublicCvControllerTests Assert.Equal("ada-cv.pdf", result.FileDownloadName); Assert.Equal([1, 2, 3], result.FileContents); pdf.Verify(x => x.ExportAsync( + user.Id, It.Is(value => value.TemplateId == "modern" && value.Html == "CV"), It.IsAny()), Times.Once); } diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index ff50323..d16b312 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -190,7 +190,7 @@ public sealed class CvVariantController : ControllerBase if (user is null) return Unauthorized(); var render = await _variants.RenderAsync(user.Id, id, Person(user), ct); if (render is null) return NotFound(); - var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); + var artifact = await _pdf.ExportAsync(user.Id, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); return File(artifact.Bytes, "application/pdf", artifact.FileName); } diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 57a805a..db4edfb 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -62,7 +62,7 @@ namespace JobTrackerApi.Controllers private sealed class ThrowingCvPdfExporter : ICvPdfExporter { - public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + public Task ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { throw new InvalidOperationException("CV PDF export is not configured for this controller instance."); } @@ -1736,7 +1736,7 @@ Candidate CV/profile: ? null : AvatarStorage.Resolve(user.AvatarImageDataUrl); var rendered = RenderTailoredCv(job, document, user, photoDataUrl); - var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken); + var artifact = await _cvPdfExporter.ExportAsync(user.Id, rendered, cancellationToken); return File(artifact.Bytes, "application/pdf", artifact.FileName); } diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 226b555..e827876 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -108,7 +108,7 @@ public sealed partial class ProfileCvController : ControllerBase private sealed class ThrowingCvPdfExporter : ICvPdfExporter { - public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + public Task ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { throw new InvalidOperationException("CV PDF export is not configured for this controller instance."); } @@ -479,7 +479,9 @@ public sealed partial class ProfileCvController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export."); } - var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken); + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var artifact = await _cvPdfExporter.ExportAsync(user.Id, new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken); return File(artifact.Bytes, "application/pdf", artifact.FileName); } diff --git a/JobTrackerApi/Controllers/PublicCvController.cs b/JobTrackerApi/Controllers/PublicCvController.cs index 77b6d75..42ef278 100644 --- a/JobTrackerApi/Controllers/PublicCvController.cs +++ b/JobTrackerApi/Controllers/PublicCvController.cs @@ -52,7 +52,7 @@ public sealed class PublicCvController : ControllerBase if (result is null) return NotFound(); var render = result.Value.render; - var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); + var artifact = await _pdf.ExportAsync(ownerId, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct); return File(artifact.Bytes, "application/pdf", artifact.FileName); } diff --git a/JobTrackerApi/Services/AppPaths.cs b/JobTrackerApi/Services/AppPaths.cs index 337fe43..9b219c9 100644 --- a/JobTrackerApi/Services/AppPaths.cs +++ b/JobTrackerApi/Services/AppPaths.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using System.Security.Cryptography; +using System.Text; namespace JobTrackerApi.Services { @@ -57,6 +59,18 @@ namespace JobTrackerApi.Services if (string.IsNullOrWhiteSpace(folder)) return Path.Combine(DataRoot, "exports"); return Path.IsPathRooted(folder) ? folder : Path.Combine(DataRoot, folder); } + + public static string GetOwnerStorageKey(string ownerUserId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(ownerUserId))).ToLowerInvariant(); + } + + public string GetOwnerCvExportsRoot(string ownerUserId) => + Path.Combine(CvExportsRoot, GetOwnerStorageKey(ownerUserId)); + + public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) => + Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId)); } } diff --git a/JobTrackerApi/Services/DailyExportHostedService.cs b/JobTrackerApi/Services/DailyExportHostedService.cs index 4b8ebdf..5fc6c7e 100644 --- a/JobTrackerApi/Services/DailyExportHostedService.cs +++ b/JobTrackerApi/Services/DailyExportHostedService.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; @@ -96,10 +94,9 @@ public sealed class DailyExportHostedService( Rules = await RulesEngine.GetSettings(db, cancellationToken), }; - var folder = paths.GetExportsRoot(configuration["Exports:DailyFolder"]); + var folder = paths.GetOwnerDailyExportsRoot(configuration["Exports:DailyFolder"], owner); Directory.CreateDirectory(folder); - var ownerKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(owner))).ToLowerInvariant(); - var finalPath = Path.Combine(folder, $"daily_export_{ownerKey}_{DateTime.Now:yyyyMMdd}.json"); + var finalPath = Path.Combine(folder, $"daily_export_{DateTime.Now:yyyyMMdd}.json"); var temporaryPath = finalPath + $".{Guid.NewGuid():N}.tmp"; try { diff --git a/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs b/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs index ae40784..fc8e3ff 100644 --- a/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs +++ b/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs @@ -8,7 +8,7 @@ public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] B public interface ICvPdfExporter { - Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken); + Task ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken); } public sealed class PlaywrightCvPdfExporter : ICvPdfExporter @@ -36,17 +36,18 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter _retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365); } - public async Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) + public async Task ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays)); - var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd")); + var folder = Path.Combine(_paths.GetOwnerCvExportsRoot(ownerUserId), now.ToString("yyyyMMdd")); Directory.CreateDirectory(folder); - var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName) + var suggestedFileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName) ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" - : renderResult.SuggestedFileName; - var storagePath = Path.Combine(folder, fileName); + : Path.GetFileName(renderResult.SuggestedFileName); + var fileName = string.IsNullOrWhiteSpace(suggestedFileName) ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" : suggestedFileName; + var storagePath = Path.Combine(folder, $"{Guid.NewGuid():N}.pdf"); var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n")); var htmlPath = Path.Combine(tempRoot, "document.html"); @@ -118,19 +119,36 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter foreach (var directory in Directory.EnumerateDirectories(_paths.CvExportsRoot)) { var name = Path.GetFileName(directory); - if (!DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue; + if (DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var legacyDate)) + { + TryDeleteExpired(directory, legacyDate, cutoff); + continue; + } - try + foreach (var datedDirectory in Directory.EnumerateDirectories(directory)) { - Directory.Delete(directory, recursive: true); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory); + var datedName = Path.GetFileName(datedDirectory); + if (DateOnly.TryParseExact(datedName, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + TryDeleteExpired(datedDirectory, date, cutoff); + } } } } + private void TryDeleteExpired(string directory, DateOnly date, DateOnly cutoff) + { + if (date >= cutoff) return; + try + { + Directory.Delete(directory, recursive: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory); + } + } + private static IReadOnlyList BuildArguments(string storagePath, string htmlPath) { return new[] diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index 5e4a325..ae7d59e 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -204,3 +204,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-170 | Accessibility code audit; focused/full frontend; optimized build; full and targeted authenticated Playwright; computed-style contrast and nested-frame overflow probes | Repository root / `job-tracker-ui` | Close confirmed icon-name, keyboard CV-card, dark Alert contrast and fixed-width public-CV accessibility defects | PASS — static audit finds no `IconButton` without an explicit name; focused 4 suites/9 tests, full frontend 54 suites/228 tests, build, full Playwright 7/7 and final targeted 2/2. Chromium measures dark missing-job Alert contrast >= 4.5:1 and proves a 375px public CV retains a full A4 inner viewport with no inner or outer horizontal overflow | Local synthetic account/CV only; no native screen reader, operating-system high-contrast mode or production environment. Jest retains the known force-exit/open-handle warning; GSI repeats its existing initialization warning in development | Scoped cross-application accessibility repository/browser work verified; native AT and production remain | | V-171 | Public/product claim inventory; plan/notice/usage focused Jest; current entitlement/billing-policy backend slice; full frontend; optimized build; full Playwright | Repository root / `job-tracker-ui` | Replace contradictory plan/commercial claims and prove respectful Free/Pro promotion without changing billing or enforcement | PASS — exactly Free/Pro comes from one catalogue; focused frontend 7 suites/30 tests, policy/billing backend 30/30, full frontend 57 suites/232 tests, build and Playwright 8/8. Chromium proves retired claims absent, explicit Light/Dark, 375/768/1440 no overflow and keyboard Free/Pro actions | Local synthetic account only; no Stripe checkout/webhook/portal, native AT or production. Commercial terms remain intentionally absent until configured Checkout; existing Jest/GSI warnings remain | PRODUCT-001 repository/browser scope verified; configured billing lifecycle and production remain | | V-172 | Action-matrix reconciliation; full backend/frontend/sidecar/build/Compose/preflight gates; expanded authenticated and anonymous Playwright | Repository root / `job-tracker-ui` / `tools/summarizer` | Complete VER-001 local release regression without promoting mocked/provider/external checks | PASS — backend 647/647, frontend 57 suites/232 tests, sidecar 22/22, production build, Compose config, API-down/wrong-base/malformed-JSON preflight and Chromium 9/9. Browser covers admin deployment identity/normal-user absence, notifications, honest Free, jobs, Career/CV, Kanban, responsive themes and public PDF | No external provider, private data, native AT or production mutation. Optional Compose variables remain unset; existing Jest/GSI/SWIG warnings remain. Windows CRLF materialization was normalized for shell execution; indexed LF policy was already correct | VER-001 verified locally; remote CI/provider/native-AT/production cells remain | +| V-173 | Owner-path trace; focused CV/export/controller/background tests; full backend; build and diff hygiene | Repository root | Establish attributable generated-file ownership before SEC-009 export/deletion | PASS — CV PDFs use opaque owner/date/UUID storage while preserving download names; daily exports use opaque owner directories and atomic writes; legacy/new retention paths are covered. Focused 77/77 and backend 647/647 | No existing file moved or deleted. Legacy shared-date generated files are intentionally not guessed. No migration, production path or private data used | SEC-009 owner-scoped generated-output prerequisite verified locally | diff --git a/docs/verification/sec-009-account-lifecycle.md b/docs/verification/sec-009-account-lifecycle.md new file mode 100644 index 0000000..afe6610 --- /dev/null +++ b/docs/verification/sec-009-account-lifecycle.md @@ -0,0 +1,36 @@ +# SEC-009 account export and deletion lifecycle + +Updated: 2026-08-15 + +Status: `IN PROGRESS`. Generated-output ownership is now explicit. Readable export and the disabled deletion lifecycle remain to be implemented. + +## Owner inventory boundary + +The authoritative inventory must include Identity-safe account/profile fields and roles; companies, opportunities, applications and all workspace children; correspondence/events/attachments; Career Profile and versions/children; CV variants/versions/artifacts/extraction runs; AI notes/interactions/operations/notifications; email drafts/send metadata; provider connection metadata; rules; sessions/trusted-device metadata; and owned files. It must exclude password/security hashes, TOTP/recovery/token hashes, OAuth tokens, IMAP passwords, data-protection keys and global settings. + +## Checkpoint 1 — owner-scoped generated files + +- `AppPaths.GetOwnerStorageKey` provides one opaque SHA-256 owner directory key. +- CV PDF exports now write under `CvExports///.pdf`. The friendly renderer filename remains the download name, while the stored UUID prevents collisions and unsafe path influence. +- Daily exports now write under `exports//daily_export_.json` with the existing atomic temporary-file move. +- The PDF exporter receives the authenticated/public-variant owner explicitly from every controller, including anonymous public download after slug ownership resolution. +- Retention prunes both legacy top-level date directories and new owner/date directories. Unknown folders remain untouched. + +No existing generated file is moved or guessed. Legacy shared-date outputs stay a separately reviewed rollout concern because they cannot be attributed safely. + +## Verification + +- Focused CV/export/controller/background tests: 77/77. +- Full backend: 647/647. +- Backend build: pass, zero warnings/errors. +- `git diff --check`: pass aside from line-ending notices. + +## Remaining repository work + +1. Implement one owner inventory used by both readable ZIP export and deletion. +2. Add manifest/checksums/warnings and include safely owned binary files without exposing storage paths. +3. Add the additive deletion state/request/file schema and disabled coordinator. +4. Add pending-account authentication/mutation gates, session/queue cancellation, provider cleanup and idempotent file quarantine/database purge. +5. Add separate tombstone storage/replay and settings/admin UX while keeping production activation disabled. + +Production retention, legal hold and restored-backup decisions remain recorded in `BLOCKERS.md`. diff --git a/docs/work-programmes/decisions.md b/docs/work-programmes/decisions.md index 753f125..5b94786 100644 --- a/docs/work-programmes/decisions.md +++ b/docs/work-programmes/decisions.md @@ -729,3 +729,13 @@ - **Consequences:** public claims stay stable across deployment-specific commercial configuration; Free users see honest locked states and retain manual/existing content; checkout terms remain inspectable at the payment boundary. - **User approval required:** No; this implements the approved master-plan requirement without external billing action. - **Reversible:** Revert the PRODUCT-001 presentation commit. Server entitlement and stored billing state are unchanged. + +## DEC-074 — Attribute generated files through opaque owner roots + +- **Date:** 2026-08-15 +- **Decision:** Store new CV PDF and daily export files beneath a deterministic SHA-256 owner directory. Use a UUID as the stored PDF filename while preserving the friendly renderer name only for download. Retain support for pruning legacy date-root CV output without moving or assigning old files. +- **Reason/evidence:** SEC-009 cannot safely export or delete shared date/candidate-derived paths because no durable record attributes them to a user. A one-way owner directory is stable, avoids raw identity disclosure in paths and gives inventory/deletion an exact root. +- **Alternatives considered:** guess ownership from candidate/date filenames; add a database row for every ephemeral PDF; embed raw user IDs in paths; move all legacy outputs. These risk cross-user attribution, unnecessary schema, identity leakage or destructive migration. +- **Consequences:** all new generated outputs have an exact owner boundary and collision-resistant storage path. Existing legacy files age out under retention and remain excluded from user deletion unless independently attributed. +- **User approval required:** No; additive storage hardening within the requested account lifecycle, with no existing data mutation. +- **Reversible:** Restore shared date paths for future files. Existing owner-scoped files remain valid retention artifacts and must not be bulk-moved or deleted during rollback. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index bd9877f..1ede4ec 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -3,7 +3,7 @@ Updated: 2026-08-15 - **Overall programme status:** Active. Eight packages are locally verified; twenty-four are implemented with verification incomplete; SEC-009 is in progress. The prioritized admin-only version indicator and every immediate repository/browser item are implemented on the release branch; remote and production verification remain. -- **Current work package:** `SEC-009` — complete readable export and account deletion lifecycle (`IN PROGRESS`). Proceed with owner inventory and a disabled/dark repository launch while retention/restore policy continues to block production activation. +- **Current work package:** `SEC-009` — complete readable export and account deletion lifecycle (`IN PROGRESS`). Generated CV/daily outputs now use opaque owner directories; proceed with the shared owner inventory/readable ZIP, then the disabled deletion lifecycle while retention/restore policy blocks production activation. - **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates. - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002, DEP-001 and VER-001 (`VERIFIED LOCALLY`). - **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider gates remain where recorded. diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index de7b865..1b3de7f 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -270,9 +270,9 @@ This queue records the highest-value work that can proceed without production cr - **Required production verification:** backup retention/tombstone rehearsal before self-service enablement. - **Status:** `IN PROGRESS`. - **Blocker:** legal/operator retention and production restore decisions block activation, not the repository-side disabled/dark launch. -- **Evidence:** audit JT-009 inventory/design. +- **Evidence:** audit JT-009 inventory/design; `docs/verification/sec-009-account-lifecycle.md`; V-173 owner-scoped generated-output checkpoint, focused 77/77 and backend 647/647. - **Commit:** none. -- **Remaining work:** owner inventory/export first, deletion second. +- **Remaining work:** owner inventory/readable ZIP export next; then additive disabled deletion coordinator, tombstone replay, UI and failure/restart verification. Production activation remains blocked by retention/restore policy. ### CORE-001 — Restore default SQLite/MariaDB behavior parity diff --git a/docs/work-programmes/session-handoff.md b/docs/work-programmes/session-handoff.md index 54bc1e5..de12534 100644 --- a/docs/work-programmes/session-handoff.md +++ b/docs/work-programmes/session-handoff.md @@ -3,16 +3,16 @@ Updated: 2026-08-15 - **Exact current task:** begin SEC-009 with the owner inventory/readable export, then implement the deletion lifecycle behind a disabled production gate. -- **Last completed step:** reconciled VER-001 and completed the safe local release matrix, including the prioritized admin deployment identity and all immediate repository/browser work. -- **Files currently modified:** expanded Playwright configuration/journeys plus VER-001 action-matrix and programme evidence. -- **Commands already run:** backend 647/647; frontend 57 suites/232 tests; sidecar 22/22; optimized build; Compose config; preflight negative cases; full Playwright 9/9; diff hygiene. +- **Last completed step:** established owner-scoped storage for every newly generated CV PDF and daily export without moving unattributable legacy files. +- **Files currently modified:** `AppPaths`, CV PDF exporter/controller callers, daily export worker, focused tests and SEC-009 evidence. +- **Commands already run:** SEC-009 storage slice 77/77; full backend 647/647; backend build; diff hygiene. The preceding VER-001 frontend/sidecar/build/Compose/preflight/Playwright 9/9 evidence remains current. - **Test results:** all listed local gates pass. Provider/native-AT/production cells remain explicitly partial, not run or blocked. Jest retains the documented force-exit/open-handle notice. - **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed. - **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed. - **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred. - **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred. -- **Uncommitted changes:** V-172 test/evidence increment only; no application dependency, schema or production configuration change. V-166 through V-171 are pushed as `a6cffe0`, `f0b9b22`, `3b86ea2`, `deed948`, `a7c2549` and `a25c31b`. +- **Uncommitted changes:** V-173 owner-storage code/tests/docs; no dependency, schema or production configuration change. V-172 is pushed as `0d48712`. - **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007. -- **Exact next action:** commit/push V-172, then inventory every user-owned row/file/token/cache/queue boundary for SEC-009 before implementing export. +- **Exact next action:** commit/push V-173, then implement one redacted owner inventory and readable ZIP export with manifest/checksums/missing-file warnings. - **Work that can continue independently:** SEC-009 repository-side owner inventory/export and disabled deletion lifecycle. UX/JOBS/PRODUCT production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates. - **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.