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

1067 lines
47 KiB
Markdown

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