diff --git a/docs/superpowers/plans/2026-08-21-project-a-server-auth-runtime-projection.md b/docs/superpowers/plans/2026-08-21-project-a-server-auth-runtime-projection.md new file mode 100644 index 00000000..cf341df7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-project-a-server-auth-runtime-projection.md @@ -0,0 +1,1066 @@ +# 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/ThothII-next/.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.