# Project A Server Authentication Runtime Projection Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the opt-in Linux server contract that keeps canonical authentication root-only, atomically publishes a verified `10001:10001` runtime projection after every authentication mutation, and fails closed across CLI, backend, Compose, and restore. **Architecture:** Three preparatory tasks add unreachable projection primitives, an inactive backend provider, and an inactive host transaction coordinator. One activation task then enables the descriptor, Compose override, backend environment selection, CLI wiring, lifecycle gates, and transactional restore in one commit; a final task supplies cross-layer hardening, documentation, and complete verification. **Tech Stack:** Go 1.26.5 standard library plus existing `flock`/YAML dependencies, Linux `openat`/`O_NOFOLLOW` filesystem operations, TypeScript on Node.js, Fastify authentication providers, Docker Compose, Vitest, shell contract gates, Markdown. ## Global Constraints - Source baseline is `a21e2c1`, whose approved design is `docs/superpowers/specs/2026-08-21-project-a-server-auth-runtime-projection-design.md`. - Work only in `/home/chirone/Thoth/.worktrees/dwh-rest-installation-auth`; use `apply_patch` for edits and preserve unrelated worktree changes. - Do not start Project A or create Project A containers, networks, or volumes. Compose commands in this plan are `config` renders only. - Do not modify or reload Nginx, stop or alter the legacy ThothII stack, or modify DWH, ETL, Supabase, Authentik, Aritmolab, `dwh-auth`, or shared services. - Do not read or modify real `/srv/thothii` authentication data. All credentials and filesystem trees in tests are synthetic temporary fixtures. - Do not create a host user or group for `10001`; projected production descriptors require numeric UID and GID exactly `10001`. - Canonical storage remains effective-owner-only `0700/0600`. Runtime projection storage is `10001:10001 0700/0600` and is mounted read-only into `core` only. - Never weaken existing generic `safeio` owner-equals-effective-UID validation. Projection I/O uses a distinct Linux implementation with explicit expected UID/GID. - A projected mutation succeeds only after canonical and runtime revisions are equal. Restore of authentication entries uses the same blocked/publish transaction. - Backend projection loading returns a complete immutable in-memory auth-plus-users snapshot; it never reopens `users.yaml` after capturing the generation. - No runtime fallback is allowed. With `THT_AUTH_RUNTIME_PROJECTION_ROOT`, the projected provider is exclusive; without it, direct-file Mac/Windows/local behavior is unchanged. - Logs, JSON, reports, and tests must not emit passwords, password hashes, complete user records, YAML contents, or secret environment values. - Use strict TDD for every behavior: write the focused test, run it and capture the intended RED, implement the minimum, then rerun GREEN. - Run Go through official `golang:1.26.5` with only this worktree bind-mounted. Add no Go or Node dependency. - T1-T3 must remain unreachable from a real installation. T4 is the single activation commit and must include descriptor, backend selection, Compose, CLI, pre-start, and restore wiring together. --- ### Task 1: Add inactive Linux runtime-projection format and publication primitives **Files:** - Create: `tools/tht/internal/authprojection/types.go` - Create: `tools/tht/internal/authprojection/format.go` - Create: `tools/tht/internal/authprojection/projection_linux.go` - Create: `tools/tht/internal/authprojection/projection_unsupported.go` - Create: `tools/tht/internal/authprojection/format_test.go` - Create: `tools/tht/internal/authprojection/projection_linux_test.go` **Interfaces:** - Consumes: exact projection layout, selector schemas, generation formula, retention, and recovery namespaces from the approved design. - Produces: ```go package authprojection const SchemaVersion = 1 var ( ErrBlocked = errors.New("authentication runtime projection is blocked") ErrIntegrity = errors.New("authentication runtime projection integrity failure") ErrUnsupported = errors.New("authentication runtime projection is unsupported") ) type Spec struct { RuntimeRoot string UID uint32 GID uint32 } type Snapshot struct { Mode string Auth []byte Users []byte AuthSHA256 string UsersSHA256 string Generation string CanonicalRevision string } type Selector struct { Version int `json:"version"` State string `json:"state"` Transaction string `json:"transaction"` Generation string `json:"generation,omitempty"` PreviousGenerations []string `json:"previousGenerations,omitempty"` } type Status struct { Selector Selector Snapshot Snapshot } type Transaction struct { spec Spec transactionID string before *Snapshot prior *Status rootFD int lockFD int blocked bool closed bool } func NewSnapshot(mode string, auth, users []byte) (Snapshot, error) func Inspect(spec Spec) (Status, error) func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, error) func (transaction *Transaction) Commit(after Snapshot) (Status, error) func (transaction *Transaction) RestoreIfUnchanged(current Snapshot) error func (transaction *Transaction) Close() error ``` - `Begin` opens and validates the runtime-root directory without following links, takes `syscall.Flock(rootFD, LOCK_EX)` on that stable directory inode, validates `CURRENT` once, and captures the complete prior `Status` before publishing a strict blocked `CURRENT`. Restore and retention decisions use only that captured prior selector/snapshot, never a second mutable history read. `Commit` stages, fsyncs, renames, verifies, selects ready, then retains current plus two predecessors. `Close` unlocks and releases descriptors only and never changes selector state. No lock file or other runtime-root entry is created; locking `CURRENT` is forbidden because `CURRENT` is atomically replaced. - `projection_unsupported.go` exposes the same functions on non-Linux and returns `ErrUnsupported`; it keeps Windows/macOS compilation green without enabling publication. - [ ] **Step 1: Write strict format and generation RED tests** Add table-driven tests with these exact assertions: ```go func TestNewSnapshotUsesDomainSeparatedGeneration(t *testing.T) { got, err := NewSnapshot("local", []byte("auth\n"), []byte("users\n")) if err != nil { t.Fatal(err) } auth := sha256.Sum256([]byte("auth\n")) users := sha256.Sum256([]byte("users\n")) record := fmt.Sprintf("thothii-auth-projection-v1\nmode=local\nauth=%x\nusers=%x\n", auth, users) generation := sha256.Sum256([]byte(record)) if got.Generation != hex.EncodeToString(generation[:]) { t.Fatalf("generation = %q", got.Generation) } if got.CanonicalRevision != "sha256:"+got.Generation { t.Fatalf("revision = %q", got.CanonicalRevision) } } func TestNewSnapshotRejectsInvalidModeAndUsersShape(t *testing.T) { for _, tc := range []struct { mode string; users []byte }{ {mode: "unknown", users: nil}, {mode: "local", users: nil}, {mode: "oidc", users: []byte("unexpected")}, } { if _, err := NewSnapshot(tc.mode, []byte("auth"), tc.users); !errors.Is(err, ErrIntegrity) { t.Fatalf("NewSnapshot(%q) error = %v", tc.mode, err) } } } ``` Also test strict JSON decoding for exactly the ready and blocked selector fields, duplicate/unknown fields, lowercase 32/64-hex IDs, at most two unique previous generations, bounded file sizes, manifest filename/size/digest equality, and canonical JSON with one trailing newline. A common object-level token parser must reject duplicate keys before typed decoding. Manifest entries are an exact mode-dependent allowlist: `auth.yaml` always, plus `users.yaml` only for local mode. - [ ] **Step 2: Run the focused format tests and capture RED** Run: ```bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/authprojection -run 'TestNewSnapshot|TestSelector|TestManifest' -count=1 ``` Expected: compilation fails because package `internal/authprojection` and the specified types/functions do not exist. - [ ] **Step 3: Implement the strict OS-independent format** Implement `types.go` and `format.go` with copied bounded byte slices, `sha256.Sum256`, lowercase hex, `json.Decoder.DisallowUnknownFields`, explicit duplicate-key token checking, exactly one JSON value, and the four-line LF-terminated generation record. Use constants: ```go const ( maximumAuthBytes = 1 << 20 maximumUsersBytes = 1 << 20 maximumSelectorBytes = 4096 maximumManifestBytes = 4096 runtimeDirectoryMode = 0o700 runtimeFileMode = 0o600 retainedPredecessors = 2 ) ``` Do not import `authconfig`; the caller supplies already validated canonical bytes and mode. - [ ] **Step 4: Run focused format GREEN** Run the Step 2 command. Expected: all format tests PASS. - [ ] **Step 5: Write Linux filesystem/publication RED tests** In `projection_linux_test.go`, use `Spec{RuntimeRoot: root, UID: uint32(os.Geteuid()), GID: uint32(os.Getegid())}` so tests do not require root. Add deterministic cases proving: ```go func TestBeginCommitPublishesOneVerifiedGeneration(t *testing.T) func TestInspectRejectsMissingBlockedMalformedAndTamperedCurrent(t *testing.T) func TestInspectRejectsWrongModeOwnerHardlinkSymlinkAndUnexpectedEntry(t *testing.T) func TestCommitLeavesBlockedAfterInjectedStageWriteFsyncRenameAndVerifyFailure(t *testing.T) func TestRestoreIfUnchangedRestoresPriorReadyOnlyForEqualCanonicalSnapshot(t *testing.T) func TestRecoveryRemovesOnlyRecordedSafeStageAndStrictCurrentTemporary(t *testing.T) func TestRecoveryRefusesUnrelatedOrUnsafeTemporaryEntries(t *testing.T) func TestRetentionKeepsCurrentAndTwoPredecessors(t *testing.T) func TestConcurrentTransactionsNeverPublishMixedGeneration(t *testing.T) func TestBeginCommitUsesNumericUIDGID10001WhenRoot(t *testing.T) func TestConcurrentBeginSerializesAcrossCurrentRenameWithoutCreatingLockEntry(t *testing.T) ``` Introduce package-private syscall seams with one test-only setter returning a restore closure: ```go type testHooks struct { beforeStageRename func() error beforeCurrentRename func() error beforeFinalVerify func() error beforeRetention func() error } func setTestHooksForTest(hooks testHooks) func() ``` Tests must assert only public IDs/digests and synthetic sentinel absence from errors. - [ ] **Step 6: Run Linux publication tests and capture RED** Run: ```bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/authprojection -run 'TestBegin|TestInspect|TestCommit|TestRestore|TestRecovery|TestRetention|TestConcurrent' -count=1 ``` Expected: compilation fails because Linux publication functions and test seams are absent. - [ ] **Step 7: Implement descriptor-relative Linux publication** Implement `projection_linux.go` with `openat`, `mkdirat`, `fstatat`/`fstat`, `O_NOFOLLOW`, `O_CLOEXEC`, `fchown`, `fchmod`, file and directory `fsync`, and same-filesystem `renameat`. The confined tree remover must open each directory without following links, accept only bounded known names/types, unlink regular files, then remove the empty directory. It may replace a corrupt target only while selector state is blocked and its structural ownership/mode/link validation passes. The unprivileged suite proves all ownership logic against the current UID/GID. Add a root-gated metadata test that uses numeric `10001:10001` without resolving a host account, skips unless EUID is zero, and proves published directories/files have exact numeric ownership. This test remains an isolated temporary filesystem test and must not create a user or touch `/srv`. Use exact temporary names: ```go stageName := ".stage-" + transactionID + "-" + snapshot.Generation currentTemporary := ".current-" + transactionID + ".tmp" ``` Scan no namespace except those two forms. A stage from a different transaction remains untouched and returns `ErrIntegrity`. Verify the committed generation with the same reader used by `Inspect` before publishing ready and again after ready publication. - [ ] **Step 8: Run publication GREEN, race, vet, and cross-platform compile** Run: ```bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/authprojection -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test -race ./internal/authprojection -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go vet ./internal/authprojection docker run --rm -v "$PWD:/work" -w /work/tools/tht -e GOOS=windows -e GOARCH=amd64 golang:1.26.5 \ go test -c ./internal/authprojection -o /tmp/authprojection.test.exe ``` Expected: Linux tests/race/vet PASS; Windows test binary compiles through the unsupported implementation. - [ ] **Step 9: Commit Task 1 only** ```bash git add tools/tht/internal/authprojection git diff --cached --check git commit -m "feat(auth): add Linux runtime projection primitives" ``` ### Task 2: Add the inactive backend immutable projection provider **Files:** - Create: `backend/src/auth/runtime-projection.ts` - Create: `backend/test/auth-runtime-projection.test.ts` - Modify: `backend/src/auth/types.ts` - Modify: `backend/src/auth/config.ts` - Modify: `backend/src/auth/local-registry.ts` - Modify: `backend/test/local-registry.test.ts` **Interfaces:** - Consumes: Task 1's exact selector, manifest, generation, digest, layout, and modes. Tests use the current EUID as the expected runtime owner; production later runs as UID `10001`. - Produces: ```ts export interface LocalUserRecord { id: string; username: string; normalizedUsername: string; displayName?: string; passwordHash: string; roles: readonly Role[]; enabled: boolean; authRevision: number; } export interface RuntimeProjectionSnapshot { generation: string; canonicalRevision: string; localUsers?: readonly LocalUserRecord[]; } export interface LoadedAuthConfig { value: AuthenticationConfig; revision: string; sourcePath: string; runtimeProjection?: RuntimeProjectionSnapshot; } export function createProjectedAuthenticationConfigProvider( root: string, ): AuthenticationConfigProvider; ``` - `sourcePath` for a projected snapshot is the immutable generation's `auth.yaml`; direct-provider behavior is unchanged. - `createCurrentLocalUserRegistryResolver` uses `runtimeProjection.localUsers` when present and creates an immutable in-memory registry; it uses the existing path-backed registry otherwise. - Task 2 exports the provider but does not read `THT_AUTH_RUNTIME_PROJECTION_ROOT`; no production configuration can select it until Task 4. - [ ] **Step 1: Write provider RED fixtures and strict-state tests** Create a `writeReadyProjection(root, fixture)` helper that writes only synthetic data and returns the expected generation. The positive test must contain these concrete assertions: ```ts test("loads ready projection as one immutable auth and local-users snapshot", () => { const root = mkdtempSync(join(tmpdir(), "tht-auth-projection-")); const fixture = localProjectionFixture("synthetic-user", "$argon2id$synthetic-sentinel"); const generation = writeReadyProjection(root, fixture); const loaded = createProjectedAuthenticationConfigProvider(root).current(); expect(loaded.revision).toBe(`sha256:${generation}`); expect(loaded.runtimeProjection?.generation).toBe(generation); expect(loaded.runtimeProjection?.canonicalRevision).toBe(`sha256:${generation}`); expect(Object.isFrozen(loaded.runtimeProjection?.localUsers)).toBe(true); expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0])).toBe(true); expect(Object.isFrozen(loaded.runtimeProjection?.localUsers?.[0]?.roles)).toBe(true); }); ``` Add named, non-empty tests for missing/blocked/malformed/duplicate-field/unknown-version `CURRENT`; traversal, symlink, hardlink, permissive mode, wrong owner, and unexpected entries; changed manifest/generation/size/digests; atomic generation switching; and absence of direct-file fallback. Add deterministic replacement seams for both the `generations` directory and the selected generation directory. Each denial case must assert the password-hash sentinel is absent from the thrown error. - [ ] **Step 2: Run provider tests and capture RED** Run: ```bash cd backend npx vitest run test/auth-runtime-projection.test.ts ``` Expected: FAIL because `createProjectedAuthenticationConfigProvider` and runtime snapshot types do not exist. - [ ] **Step 3: Implement strict projected loading without environment wiring** Move `LocalUserRecord` to `auth/types.ts` and export the existing strict parser functions under these names without weakening schemas: ```ts export function parseAuthenticationConfigSource(source: string): AuthenticationConfig; export function parseLocalUserRegistrySource(source: string): readonly LocalUserRecord[]; ``` Implement `runtime-projection.ts` with bounded descriptor-based reads, `O_NOFOLLOW`, exact owner=`process.geteuid()`, modes and link counts, strict JSON, safe lowercase generation names, per-file SHA-256 verification, and the exact domain-separated generation formula. For `CURRENT`, `generations`, the selected generation directory, `manifest.json`, `auth.yaml`, and `users.yaml`, perform bounded `lstat -> open/read -> fstat/lstat` identity checks and fail closed on every symlink/type/link/owner/mode/device/inode mismatch. Read and parse `auth.yaml` and `users.yaml` before returning. Only an atomic `CURRENT` replacement receives one full-load retry; replacement of `generations` or a selected generation directory is an immediate sanitized integrity failure. Update every existing import of `LocalUserRecord` to `auth/types.ts` and keep a compatibility re-export from `local-registry.ts` only if an existing public import contract requires it. - [ ] **Step 4: Run provider GREEN and direct-provider regressions** Run: ```bash cd backend npx vitest run test/auth-runtime-projection.test.ts test/auth-config.test.ts test/local-registry.test.ts npx tsc --noEmit -p . ``` Expected: all focused tests PASS and TypeScript reports no errors. - [ ] **Step 5: Write and run the in-flight snapshot/GC regression** The test must: 1. load generation A through the provider; 2. retain its `LoadedAuthConfig` without resolving a user; 3. atomically select B and delete A from disk; 4. call `createCurrentLocalUserRegistryResolver().resolve(loadedA)`; 5. authenticate A's synthetic user successfully from the in-memory snapshot; 6. load a new snapshot and authenticate only B. Run: ```bash cd backend npx vitest run test/auth-runtime-projection.test.ts -t "in-flight" ``` Expected: PASS without reopening generation A. - [ ] **Step 6: Run backend focused race-equivalent repetition and commit Task 2** Run the provider suite ten times and then commit only Task 2 files: ```bash cd backend for run in 1 2 3 4 5 6 7 8 9 10; do npx vitest run test/auth-runtime-projection.test.ts || exit 1; done cd .. git add backend/src/auth/runtime-projection.ts backend/src/auth/types.ts backend/src/auth/config.ts \ backend/src/auth/local-registry.ts backend/test/auth-runtime-projection.test.ts backend/test/local-registry.test.ts git diff --cached --check git commit -m "feat(auth): load immutable runtime projection snapshots" ``` ### Task 3: Add the inactive canonical-to-projection transaction coordinator **Files:** - Create: tools/tht/internal/authconfig/projection_transaction.go - Create: tools/tht/internal/authconfig/projection_transaction_test.go - Modify: tools/tht/internal/authconfig/store.go - Modify: tools/tht/internal/authconfig/store_test.go **Interfaces:** - Consumes Task 1 publication primitives and the existing canonical .auth.lock store. - Produces: ~~~go package authconfig type ProjectionSpec struct { RuntimeRoot string UID uint32 GID uint32 } type ProjectionStatus struct { State string Generation string CanonicalRevision string Equal bool } type ExternalProjectionTransaction struct { canonicalRoot string spec authprojection.Spec outerLock *flock.Flock projection *authprojection.Transaction before authprojection.Snapshot changed bool published bool closed bool } func RunProjectedMutation( ctx context.Context, canonicalRoot string, spec ProjectionSpec, mutate func() error, ) error func PublishProjectedCanonical( ctx context.Context, canonicalRoot string, spec ProjectionSpec, ) (ProjectionStatus, error) func BeginExternalProjectionTransaction( ctx context.Context, canonicalRoot string, spec ProjectionSpec, ) (*ExternalProjectionTransaction, error) func (transaction *ExternalProjectionTransaction) PublishCanonical() (ProjectionStatus, error) func (transaction *ExternalProjectionTransaction) RestorePriorIfCanonicalUnchanged() error func (transaction *ExternalProjectionTransaction) Close() error ~~~ - The coordinator acquires canonical .auth-transaction.lock mode 0600 before calling authprojection.Begin; the existing .auth.lock is always acquired inside it. Lock order is outer transaction lock, projection lock, canonical store lock. - It reads validated canonical auth.yaml and mode-dependent users.yaml bytes into a copied authprojection.Snapshot; no caller supplies serialized secret bytes. - RunProjectedMutation blocks B before mutate, publishes and verifies after mutation, and returns success only when revisions are equal. If mutation fails and canonical bytes are unchanged it restores the captured ready selector; otherwise it leaves B blocked. - ExternalProjectionTransaction exists for restore. Each PublishCanonical call republishes the then-current canonical state while retaining the same outer lock; a later recovery publish first re-blocks any candidate generation and then publishes the recovered checkpoint generation. - Task 3 exports this API for later wiring but does not change command dispatch, installation parsing, environment selection, Compose, setup, start, doctor, or restore. - [ ] **Step 1: Write outer-lock and canonical-snapshot RED tests** Add non-empty table-driven tests: ~~~go func TestRunProjectedMutationHoldsOuterLockAcrossCanonicalAndProjection(t *testing.T) func TestRunProjectedMutationPublishesExactLocalAndOIDCSnapshots(t *testing.T) func TestRunProjectedMutationRestoresPriorReadyWhenMutationFailsWithoutChangingCanonical(t *testing.T) func TestRunProjectedMutationLeavesBlockedWhenMutationChangesCanonicalThenFails(t *testing.T) func TestRunProjectedMutationLeavesBlockedWhenPublicationOrVerificationFails(t *testing.T) func TestPublishProjectedCanonicalRepairsBlockedStateFromCanonicalOnly(t *testing.T) func TestExternalProjectionTransactionRepublishesRecoveredCanonicalUnderOneOuterLock(t *testing.T) func TestExternalProjectionTransactionCloseNeverMakesChangedCanonicalReady(t *testing.T) func TestProjectionCoordinatorErrorsAndLogsNeverContainSyntheticPasswordsOrHashes(t *testing.T) ~~~ Use two goroutines and a deterministic lock seam to prove that neither a second CLI-style mutation nor a restore-style external transaction can enter while the first transaction is between blocked publication and final equality verification. - [ ] **Step 2: Run coordinator tests and capture RED** ~~~bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/authconfig -run 'TestRunProjected|TestPublishProjected|TestExternalProjection' -count=1 ~~~ Expected: compilation fails because the coordinator types and functions are absent. - [ ] **Step 3: Add bounded canonical snapshot loading and the outer lock** In store.go, add an unexported loadSnapshotBytes(directory) that uses the existing strict canonical readers, returns copied bytes, and validates local versus OIDC shape. Do not expose YAML contents in errors. Add .auth-transaction.lock acquisition using the existing flock dependency, canonical path validation, exact 0600, and no symlink acceptance. In projection_transaction.go, implement begin/block, mutate, publish, verify-equality, recovery, and close ordering. Every defer must preserve the primary error with errors.Join; a cleanup error must never turn a blocked or divergent state into success. - [ ] **Step 4: Run coordinator GREEN, race, and vet** ~~~bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/authconfig ./internal/authprojection -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test -race ./internal/authconfig ./internal/authprojection -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go vet ./internal/authconfig ./internal/authprojection ~~~ Expected: all commands PASS. Confirm with rg that no command/config/setup/restore/backend file references RunProjectedMutation, PublishProjectedCanonical, or BeginExternalProjectionTransaction. - [ ] **Step 5: Commit Task 3 only** ~~~bash git add tools/tht/internal/authconfig/projection_transaction.go \ tools/tht/internal/authconfig/projection_transaction_test.go \ tools/tht/internal/authconfig/store.go tools/tht/internal/authconfig/store_test.go git diff --cached --check git commit -m "feat(auth): coordinate canonical auth publication" ~~~ ### Task 4: Activate projected server authentication in one fail-closed commit **Files:** - Create: deploy/compose.auth-runtime-projection.yaml - Create: scripts/test-auth-runtime-projection-compose.sh - Modify: tools/tht/internal/config/installation.go - Modify: tools/tht/internal/config/installation_test.go - Modify: tools/tht/internal/setup/files.go - Modify: tools/tht/internal/setup/files_test.go - Modify: tools/tht/internal/setup/run.go - Modify: tools/tht/internal/setup/run_test.go - Modify: tools/tht/internal/authconfig/commands.go - Modify: tools/tht/internal/authconfig/commands_test.go - Modify: tools/tht/internal/service/service.go - Modify: tools/tht/internal/service/service_test.go - Modify: tools/tht/internal/doctor/report.go - Modify: tools/tht/internal/doctor/report_test.go - Modify: tools/tht/internal/backup/restore.go - Modify: tools/tht/internal/backup/restore_host.go - Modify: tools/tht/internal/backup/restore_test.go - Modify: tools/tht/internal/backup/create_test.go - Modify: tools/tht/cmd/tht/main.go - Modify: tools/tht/cmd/tht/main_test.go - Modify: backend/src/config.ts - Modify: backend/src/app.ts - Modify: backend/test/config.test.ts - Modify: backend/test/auth-routes-local.test.ts - Modify: backend/test/auth-request-snapshot.test.ts - Modify: scripts/test-canonical-install-compose.sh - Modify: scripts/test-compose-secret-policy.sh - Modify: scripts/test-unified-compose.sh **Descriptor and Compose contract:** ~~~yaml authentication: configDirectory: /absolute/root-only/canonical-auth runtimeProjection: directory: /absolute/runtime-auth uid: 10001 gid: 10001 ~~~ ~~~go type RuntimeProjection struct { Directory string UID uint32 GID uint32 } type Authentication struct { ConfigDirectory string RuntimeProjection *RuntimeProjection } func (installation Installation) RuntimeAuthProjection() *RuntimeProjection func (installation Installation) HasRuntimeAuthProjection() bool ~~~ - Projection is valid only for profile server, canonical absolute distinct paths, UID/GID exactly 10001, Linux mutation/start paths, and THT_AUTH_RUNTIME_ROOT equal to the descriptor directory. Without the descriptor block, THT_AUTH_RUNTIME_ROOT must be absent. - ComposeFiles order is base, profile, operator overrides, automatic deploy/compose.auth-runtime-projection.yaml, then durable current-image override. A manual occurrence of the dedicated override is rejected. - Setup-generated server descriptors use descriptor-directory/auth-runtime as the non-live default; local setup emits no projection. Project A's later operator descriptor may explicitly use /srv/thothii/secrets/auth-runtime. Server setup checks EUID zero before it creates either root; it creates canonical root as root:root 0700 and runtime root as numeric 10001:10001 0700, without creating a host account. The dedicated override is exact: ~~~yaml services: core: environment: THT_AUTH_RUNTIME_PROJECTION_ROOT: /run/thothii-auth volumes: - type: bind source: ${THT_AUTH_RUNTIME_ROOT:?set THT_AUTH_RUNTIME_ROOT} target: /run/thothii-auth read_only: true ~~~ The effective Compose render must replace the current canonical source at the same core target. It must not mount the canonical root and must not add auth mounts or environment to workspace-maintenance. **Restore dependency contract:** ~~~go type authProjectionRestoreTransaction interface { PublishCanonical() (authconfig.ProjectionStatus, error) RestorePriorIfCanonicalUnchanged() error Close() error } beginAuthProjection func( context.Context, config.Installation, ) (authProjectionRestoreTransaction, error) ~~~ - A manifest with archived canonical authentication entries begins and blocks the auth projection after archive/checkpoint staging succeeds and before the first destination mutation. - Candidate canonical restore calls PublishCanonical before any restart. Recovery checkpoint restore calls PublishCanonical again before recovery restart and verification. - Any canonical, publish, recovery-publish, verification, or cleanup failure leaves B blocked unless exact pre-mutation canonical bytes remain and RestorePriorIfCanonicalUnchanged succeeds. - Archives without authentication entries never acquire or alter the projection transaction. - Backup creation continues to archive canonical auth.yaml and users.yaml only; runtime CURRENT, manifests, generations, temporary entries, and transaction locks are excluded. - [ ] **Step 1: Write descriptor and Compose-order RED tests** Add exact cases for projected server acceptance; local rejection; noncanonical/equal paths; UID/GID mismatch; missing/mismatched environment; manual duplicate override; and exact automatic ordering after operator overrides but before current-image. Also prove every pre-existing non-projected local/server fixture still loads unchanged. Run: ~~~bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/config -run 'Test.*RuntimeProjection|Test.*ComposeFiles' -count=1 ~~~ Expected RED: descriptor fields/accessors and automatic override are absent. - [ ] **Step 2: Implement descriptor parsing and Compose ordering, but do not commit** Use strict YAML KnownFields, copy the runtime pointer in the public Installation, enforce the exact env/path/profile/UID/GID contract, and reject manual duplicate declaration by canonical path. Do not stage or commit yet; every Task 4 activation surface lands together at the final step. - [ ] **Step 3: Write setup ownership and root-gate RED tests** Add synthetic temporary tests proving: - projected server EnsureFiles refuses before any write when EUID is not zero; - a root-gated subprocess creates exact numeric root/runtime ownership and modes without user lookup; - local setup remains current-EUID private and emits no projection; - ConfigureOnly still publishes initial auth before returning; - publication failure leaves the generated projected installation blocked and reports no secret. Run the focused setup tests in the official Go container. Expected RED: no projected setup support. - [ ] **Step 4: Implement projected setup** Add an injectable effectiveUID seam. Root-check before EnsureFiles mutates a projected server tree. Render descriptor/environment paths, create canonical/runtime roots with separate validated ownership helpers, and route initial configureAuthentication through the Task 3 transaction. Do not add any host account lookup or creation. - [ ] **Step 5: Write backend environment-selection RED tests** Use concrete table-driven assertions: ~~~ts test.each([ ["relative root", { THT_AUTH_RUNTIME_PROJECTION_ROOT: "relative" }], ["conflicting direct file", { THT_AUTH_RUNTIME_PROJECTION_ROOT: "/run/thothii-auth", THT_AUTH_CONFIG_FILE: "/different/auth.yaml", }], ])("rejects projected authentication configuration: %s", (_name, env) => { expect(() => loadConfig(baseProductionEnv(env))).toThrow( "authentication configuration is invalid", ); }); ~~~ Add a positive regression where THT_AUTH_RUNTIME_PROJECTION_ROOT is absent and the current direct-file provider remains selected. Test projection-declared-but-environment-missing in the descriptor loader from Step 1, not in backend-only configuration. Also add positive projected local/OIDC routing tests. Run: ~~~bash cd backend npx vitest run test/config.test.ts test/auth-routes-local.test.ts npx vitest run test/auth-request-snapshot.test.ts ~~~ Expected RED because the new environment is not selected. - [ ] **Step 6: Activate the exclusive backend provider** When THT_AUTH_RUNTIME_PROJECTION_ROOT is present, select only createProjectedAuthenticationConfigProvider. Reject a non-default conflicting THT_AUTH_CONFIG_FILE, blocked or missing projection, and direct fallback. Keep current direct-file behavior byte-for-byte when the environment is absent. Wire app.ts so each request captures one LoadedAuthConfig and the local registry resolver consumes only its in-memory users. - [ ] **Step 7: Write CLI mutation, status, publish, and lifecycle RED tests** Table-drive every mutator: ~~~text configure user add user set-password user enable user disable user grant user revoke user logout-all ~~~ For each, prove blocked-before-write, ready-only-after-equality, unchanged-canonical rollback, changed-canonical failure remains blocked, and no password/hash output. Add these cases: - auth publish accepts no content/password arguments and repairs only from canonical; - auth status --json emits only state, public generation/revision, and equality; - auth check fails before the backend diagnostic when projection is not ready/equal; - start refuses before Compose when projection is blocked/missing/divergent; - update --check-only refuses before its Compose config render when projection is blocked/missing/divergent; - doctor reports a sanitized failed auth-projection check; - unrelated non-projected installations preserve current behavior. Run focused Go tests and capture RED before production wiring. - [ ] **Step 8: Wire CLI and lifecycle fail-closed gates** Route all listed mutation call sites through RunProjectedMutation only when the descriptor selects projection. Add publish dispatch and extend status --json without secret material. Add a shared pre-Compose readiness function used by service.Start, the main.go update --check-only path, doctor, and setup post-configuration. The gate may be bypassed only by repair commands auth configure, auth publish, and auth-bearing restore while holding its external transaction. On a projected descriptor, configure, publish, every user mutation, and auth-bearing restore must refuse before mutation unless GOOS is Linux and EUID is zero; read-only status remains non-mutating but naturally requires permission to inspect both protected roots. - [ ] **Step 9: Write restore and checkpoint RED tests** Add deterministic cases for: 1. an auth-bearing candidate blocks before the first restored auth file; 2. candidate publication completes before restart; 3. publication failure prevents restart and remains blocked; 4. later health, doctor, Pi, or workspace failure restores the checkpoint, republishes checkpoint auth, verifies equality, then permits recovery restart; 5. recovery publication failure remains blocked and suppresses admission reopening; 6. failure before auth mutation restores the exact prior ready selector only when canonical bytes are unchanged; 7. a non-auth archive uses the unchanged restore lifecycle; 8. backup manifest and payload contain canonical auth files and no runtime projection path. Run focused internal/backup tests and capture RED. - [ ] **Step 10: Integrate auth projection with restore and recovery** Add the dependency interface above, detect authentication entries from the verified manifest, and hold the external projection transaction through candidate restore, candidate publication, verification, checkpoint recovery, recovery publication, and terminal cleanup. Respect the existing lifecycle/admission lock order: acquire the new auth outer lock only after lifecycle acquisition and immutable archive staging, and before authentication destination writes. - [ ] **Step 11: Add the Compose override and executable contract gate** Create deploy/compose.auth-runtime-projection.yaml and scripts/test-auth-runtime-projection-compose.sh. The script renders synthetic temporary projected and non-projected installations with docker compose config. Assert the exact core-only read-only mount/environment, replacement of the existing canonical source at /run/thothii-auth, ordering, no canonical mount, no maintenance mount, manual duplicate rejection, and absence of secret values in output. Update existing canonical, unified, and secret-policy gates only where the automatic override changes their expected render. Run: ~~~bash bash scripts/test-auth-runtime-projection-compose.sh bash scripts/test-canonical-install-compose.sh TMPDIR=/tmp bash scripts/test-compose-secret-policy.sh bash scripts/test-unified-compose.sh ~~~ Classify any pre-existing path-sensitive baseline failure by running the same command at a21e2c1; do not weaken a gate to make it green. - [ ] **Step 12: Run Task 4 focused verification** ~~~bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test ./internal/config ./internal/setup ./internal/authconfig ./internal/authprojection \ ./internal/service ./internal/doctor ./internal/backup ./cmd/tht -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go test -race ./internal/authconfig ./internal/authprojection ./internal/backup -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 \ go vet ./... cd backend npx vitest run test/auth-runtime-projection.test.ts test/config.test.ts \ test/auth-config.test.ts test/local-registry.test.ts test/auth-routes-local.test.ts \ test/auth-request-snapshot.test.ts npx tsc --noEmit -p . cd .. ~~~ Expected: all focused gates PASS; no Project A Compose up, build, network, volume, or live path is used. - [ ] **Step 13: Audit the activation diff before staging** ~~~bash git diff -- tools/tht/internal/config tools/tht/internal/setup tools/tht/internal/authconfig \ tools/tht/internal/service tools/tht/internal/doctor tools/tht/internal/backup tools/tht/cmd/tht \ backend/src backend/test deploy/compose.auth-runtime-projection.yaml scripts git diff --check rg -n 'THT_AUTH_RUNTIME_PROJECTION_ROOT|runtimeProjection|RunProjectedMutation|beginAuthProjection' \ tools/tht backend deploy scripts ~~~ Confirm descriptor acceptance, backend selection, Compose mount, every mutator, pre-start/doctor, and auth-bearing restore are all present. If any activation edge is missing, do not commit. - [ ] **Step 14: Commit all activation surfaces together** Stage only the Task 4 files listed above, inspect git diff --cached --name-only and git diff --cached --check, then commit: ~~~bash git add deploy/compose.auth-runtime-projection.yaml scripts/test-auth-runtime-projection-compose.sh git add tools/tht/internal/config/installation.go tools/tht/internal/config/installation_test.go git add tools/tht/internal/setup/files.go tools/tht/internal/setup/files_test.go git add tools/tht/internal/setup/run.go tools/tht/internal/setup/run_test.go git add tools/tht/internal/authconfig/commands.go tools/tht/internal/authconfig/commands_test.go git add tools/tht/internal/service/service.go tools/tht/internal/service/service_test.go git add tools/tht/internal/doctor/report.go tools/tht/internal/doctor/report_test.go git add tools/tht/internal/backup/restore.go tools/tht/internal/backup/restore_host.go git add tools/tht/internal/backup/restore_test.go tools/tht/internal/backup/create_test.go git add tools/tht/cmd/tht/main.go tools/tht/cmd/tht/main_test.go git add backend/src/config.ts backend/src/app.ts git add backend/test/config.test.ts backend/test/auth-routes-local.test.ts git add backend/test/auth-request-snapshot.test.ts git add scripts/test-canonical-install-compose.sh scripts/test-compose-secret-policy.sh git add scripts/test-unified-compose.sh git diff --cached --name-only git diff --cached --check git commit -m "feat(server): activate projected authentication safely" ~~~ There must be no intermediate commit in which projected descriptors load but backend, restore, or lifecycle gating is absent. ### Task 5: Add acceptance gates, operator documentation, and final verification **Files:** - Create: scripts/test-project-a-auth-runtime-projection.sh - Modify: docs/install/server.md - Modify: docs/install/authentication-local.md - Modify: docs/install/examples/thothii-installation.server.yaml - Modify: docs/testing/authentication-manual-acceptance.md - Modify: docs/testing/psd-server-project-a-manual.md - Modify: docs/plans/2026-08-20-psd-server-project-a-standalone.md - Modify: PROJECT_STATE.md - Modify: scripts/auth-docs-smoke.sh - Modify: scripts/test-verify-workspace-install-docs.sh **Documentation contract:** - Explain A canonical root versus B runtime projection, exact owners/modes, CURRENT, generations, equality, retention, and why no host user 10001 is created. - Give exact sudo tht auth status --json and sudo tht auth publish repair flow, with blocked-state interpretation and secret-safe evidence collection. - Explain that the Compose mount is read-only and core-only; the canonical root is never mounted. - Explain restore semantics, including candidate/recovery republish and why blocked state prevents start. - State that Mac, Windows, and local direct-file authentication remains unchanged when projection is absent. - Keep Project A start as a later manual gate. No document may imply this implementation work authorizes docker compose up, Nginx changes, legacy stack changes, or /srv mutation. - [ ] **Step 1: Write the cross-layer acceptance gate and capture RED** Create a shell test that uses only temporary synthetic roots and isolated test containers. Cases: ~~~text descriptor_requires_server_uid_gid_and_matching_env compose_mount_is_core_only_read_only_and_noncanonical local_initial_configure_publishes_equal_ready oidc_initial_configure_publishes_equal_ready every_user_mutation_blocks_then_publishes publish_repairs_blocked_from_canonical start_and_doctor_fail_closed_for_missing_blocked_tampered_or_divergent backend_authenticates_from_one_immutable_generation_after_previous_gc auth_restore_publishes_candidate failed_candidate_verification_republishes_checkpoint failed_recovery_publish_remains_blocked non_auth_restore_never_touches_projection mac_windows_local_regression secret_redaction ~~~ Run the new script before its fixture driver is complete and capture the intended failing case; then finish the fixture and rerun GREEN. The script must trap cleanup and reject any Project A project name, /srv path, external network, Compose up, or live credential source. - [ ] **Step 2: Write documentation-verifier RED assertions** Extend the docs smoke/unit verifier to require every documentation bullet above and reject: - host useradd or groupadd instructions for 10001; - mounting canonical authentication into core; - direct editing of runtime generations or CURRENT; - password/YAML dumps, sudo nginx -T, raw environment output, or secret-bearing diff; - any claim that Project A was started or the legacy stack was changed. Run: ~~~bash bash scripts/test-verify-workspace-install-docs.sh bash scripts/auth-docs-smoke.sh ~~~ Expected RED: the new projection-specific instructions are absent. - [ ] **Step 3: Update operator and Project A documents** Write the exact commands and recovery decision tree. The generated example must contain numeric uid 10001, gid 10001, example directory /srv/example/thothii/auth-runtime, and no credential value. Update PROJECT_STATE.md to say implementation is prepared and tested only, while the Project A start gate still requires explicit authorization. - [ ] **Step 4: Run documentation and cross-layer GREEN** ~~~bash bash scripts/test-project-a-auth-runtime-projection.sh bash scripts/test-auth-runtime-projection-compose.sh bash scripts/test-verify-workspace-install-docs.sh bash scripts/auth-docs-smoke.sh bash -n scripts/test-project-a-auth-runtime-projection.sh bash -n scripts/test-auth-runtime-projection-compose.sh bash -n scripts/test-verify-workspace-install-docs.sh scripts/auth-docs-smoke.sh ~~~ Expected: every case PASS with no secret or live-state output. - [ ] **Step 5: Run complete layer verification** ~~~bash docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 go test ./... -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 go test -race ./... -count=1 docker run --rm -v "$PWD:/work" -w /work/tools/tht golang:1.26.5 go vet ./... cd backend npx vitest run npx tsc --noEmit -p . cd .. bash scripts/test-default-compose.sh bash scripts/test-canonical-install-compose.sh TMPDIR=/tmp bash scripts/test-compose-secret-policy.sh bash scripts/test-unified-compose.sh bash scripts/test-auth-runtime-projection-compose.sh bash scripts/test-project-a-auth-runtime-projection.sh bash scripts/test-verify-workspace-install-docs.sh bash scripts/auth-docs-smoke.sh git diff --check ~~~ For any failure, reproduce it at a21e2c1 before classifying it as baseline. Do not change unrelated Compose or documentation policy to mask a baseline problem. - [ ] **Step 6: Perform scope, dependency, and secret review** ~~~bash git status --short git diff --name-only git diff --cached --name-only git diff --name-only a21e2c1 cd tools/tht docker run --rm -v "$PWD:/work" -w /work golang:1.26.5 go list -m all cd ../.. rg -n '/srv/thothii|docker compose .*up|nginx -s reload|systemctl|useradd|groupadd' tools/tht rg -n '/srv/thothii|docker compose .*up|nginx -s reload|systemctl|useradd|groupadd' backend deploy scripts docs PROJECT_STATE.md ~~~ Review every match in context. Compare tools/tht/go.mod, tools/tht/go.sum, backend/package.json, and the lockfiles with a21e2c1. Confirm no new dependency, no live secret/value, no runtime activation command, no Nginx, legacy, or shared-service change, and no generic safeio weakening. - [ ] **Step 7: Commit documentation and gates** ~~~bash git add scripts/test-project-a-auth-runtime-projection.sh git add docs/install/server.md docs/install/authentication-local.md git add docs/install/examples/thothii-installation.server.yaml git add docs/testing/authentication-manual-acceptance.md git add docs/testing/psd-server-project-a-manual.md git add docs/plans/2026-08-20-psd-server-project-a-standalone.md PROJECT_STATE.md git add scripts/auth-docs-smoke.sh scripts/test-verify-workspace-install-docs.sh git diff --cached --check git commit -m "docs(auth): document runtime projection operations" ~~~ - [ ] **Step 8: Request independent security review and stop before live execution** Ask a Terra reviewer to inspect the complete five-commit range for descriptor/backend/restore atomicity, path and ownership safety, fail-closed lifecycle behavior, Mac/Windows regression, and secret redaction. Remediate findings with TDD in separate fix commits, rerun Step 5, and stop. The handoff must explicitly say Project A has not been started and applying the descriptor or runtime roots under /srv/thothii requires a new explicit authorization.