Files
ThothII/docs/superpowers/plans/2026-08-21-project-a-server-auth-runtime-projection.md
T

47 KiB

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:
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:

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:

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:

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:

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:

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:

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:

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:

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
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:
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:

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:

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:

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:

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:

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:

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:
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:

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
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
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
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:

authentication:
  configDirectory: /absolute/root-only/canonical-auth
  runtimeProjection:
    directory: /absolute/runtime-auth
    uid: 10001
    gid: 10001
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:

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:

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:

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:

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:

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:

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 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
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
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:

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:

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 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 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
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
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
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.