2026-05-12-enternity-login-plan.md

docs/plans/2026-05-12-enternity-login-plan.md

Enternity Login 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: Implementiere /enternity Login + /admin Stub-Dashboard für 0nefinity.love, Phase 1 (Single-User 0nefinity, Setup-Token-Flow, Argon2id, HIBP-Check, CSP report-only).

Architecture: PHP 8.3 im bestehenden Apache-Container. SQLite (/var/www/html/.auth/auth.sqlite) in separatem rw-Volume. Server-side PHP-Sessions mit HttpOnly+Secure+SameSite=Strict-Cookies. Setup über einmalig generiertes Token (File-basiert), danach Argon2id+HIBP für PW-Validation.

Tech Stack: PHP 8.3, SQLite/PDO, Argon2id (password_hash), native PHP-Sessions, Apache 2.4 + mod_rewrite, Docker Compose (Named Volume).

Spec: docs/specs/2026-05-12-enternity-login-design.md (commit 0d7f22f)

Branch-Strategie: Feature-Branch feat/enternity-login, kein direkter main-Push (Tim's Workflow). PR am Ende. Spec-Docs direkt auf main ist erlaubt (Memory feedback_no_direct_main_push.md betrifft Code).

Dev-Hinweis: Dev-Worktree ist git-broken (Memory reference_0nefinity_dev_workflow.md). Implementation findet im main-Repo statt, lokal mit claude-dev-Branch testen, dann PR auf main, Merge + Deploy.


File Structure

Neu zu erstellende Dateien:

/home/timbr/0nefinity/
├── docker-compose.yml                     ← MODIFY: Volume für .auth/
├── Dockerfile                             ← MODIFY: entrypoint hinzufügen
├── entrypoint.sh                          ← NEW: bootstrap setup-token bei container start
└── 0nefinity.love/
    ├── .htaccess                          ← MODIFY: Routing für /enternity, /admin, /api/auth/*, /.auth/ deny
    ├── enternity.php                      ← NEW: GET /enternity (Login + Setup-Modus)
    ├── admin/
    │   └── index.php                      ← NEW: GET /admin (auth-guarded stub)
    ├── api/
    │   ├── auth/
    │   │   ├── login.php                  ← NEW: POST
    │   │   ├── logout.php                 ← NEW: POST
    │   │   ├── me.php                     ← NEW: GET
    │   │   └── setup.php                  ← NEW: POST (initial setup)
    │   └── csp-report.php                 ← NEW: CSP report-only sink
    ├── .auth/                             ← NEW dir, web-blocked
    │   ├── .htaccess                      ← NEW: Require all denied
    │   ├── auth.sqlite                    ← runtime (im Volume)
    │   ├── setup-token                    ← runtime (im Volume, temporär)
    │   ├── schema.sql                     ← NEW: CREATE TABLEs
    │   ├── bootstrap.php                  ← NEW: lib (PDO, session-init, helpers)
    │   ├── csrf.php                       ← NEW: lib
    │   ├── hibp.php                       ← NEW: lib (k-anonymity-Check)
    │   └── rate-limit.php                 ← NEW: lib (sliding window + exp backoff)
    └── tools/
        └── auth-cli.php                   ← NEW: CLI: set-password, list-users, create-invite

File-Responsibilities:


Task 1: Feature-Branch erstellen + Plan auf main pushen

Files: keine Code-Änderungen, nur Branching.

cd /home/timbr/0nefinity/0nefinity.love
git fetch origin
git checkout -b feat/enternity-login origin/main
git push -u origin feat/enternity-login

Expected: Branch 'feat/enternity-login' set up to track 'origin/feat/enternity-login'

git branch --show-current

Expected: feat/enternity-login


Task 2: SQLite Schema (.auth/schema.sql)

Files:

Datei 0nefinity.love/.auth/schema.sql:

PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS users (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  username      TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  email         TEXT,
  role          TEXT NOT NULL DEFAULT 'admin',
  created_at    INTEGER NOT NULL,
  last_login_at INTEGER
);

CREATE TABLE IF NOT EXISTS login_attempts (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  ip         TEXT NOT NULL,
  username   TEXT,
  success    INTEGER NOT NULL,
  ts         INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_attempts_ip_ts ON login_attempts(ip, ts);

CREATE TABLE IF NOT EXISTS invite_tokens (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  token_hash  TEXT UNIQUE NOT NULL,
  email       TEXT,
  created_at  INTEGER NOT NULL,
  expires_at  INTEGER NOT NULL,
  used_at     INTEGER,
  used_by_uid INTEGER REFERENCES users(id)
);

Datei 0nefinity.love/.auth/.htaccess:

Require all denied
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule .* - [F,L]
</IfModule>
ls -la /home/timbr/0nefinity/0nefinity.love/.auth/

Expected: zeigt schema.sql und .htaccess

cd /home/timbr/0nefinity/0nefinity.love
git add .auth/schema.sql .auth/.htaccess
git commit -m "feat(auth): sqlite schema + .auth/ deny .htaccess"

Task 3: docker-compose.yml — Named Volume für .auth/

Files:

Edit /home/timbr/0nefinity/docker-compose.yml:

services:
  live:
    build: .
    container_name: 0nefinity-live
    restart: unless-stopped
    ports:
      - "127.0.0.1:18:80"
    volumes:
      - ./0nefinity.love:/var/www/html:ro
      - 0nefinity-auth-live:/var/www/html/.auth

  dev:
    build: .
    container_name: 0nefinity-dev
    restart: unless-stopped
    ports:
      - "127.0.0.1:129:80"
    volumes:
      - ./dev.0nefinity.love/0nefinity-dev:/var/www/html
      - 0nefinity-auth-dev:/var/www/html/.auth

volumes:
  0nefinity-auth-live:
  0nefinity-auth-dev:
cd /home/timbr/0nefinity
docker compose config > /dev/null && echo "YAML OK"

Expected: YAML OK

cd /home/timbr/0nefinity
git status 2>&1 | head -5

Falls kein git-Repo: Datei einfach speichern, kein Commit nötig (Server-lokal). Falls git-Repo: Commit mit Message chore(docker): add 0nefinity-auth volume for /.auth/


Task 4: Container-Entrypoint für Setup-Token

Files:

Datei /home/timbr/0nefinity/entrypoint.sh:

#!/usr/bin/env bash
set -euo pipefail

AUTH_DIR="/var/www/html/.auth"
DB="$AUTH_DIR/auth.sqlite"
TOKEN_FILE="$AUTH_DIR/setup-token"
SCHEMA="/var/www/html/.auth/schema.sql"

# Ensure .auth dir exists (volume-mounted, may be empty on first start)
mkdir -p "$AUTH_DIR"

# Init DB if missing
if [ ! -f "$DB" ]; then
  echo "[entrypoint] initializing auth.sqlite"
  sqlite3 "$DB" < "$SCHEMA"
fi

# Generate setup-token if no users yet AND no token-file
USER_COUNT=$(sqlite3 "$DB" "SELECT COUNT(*) FROM users;")
if [ "$USER_COUNT" = "0" ] && [ ! -f "$TOKEN_FILE" ]; then
  TOKEN=$(openssl rand -hex 32)
  echo -n "$TOKEN" > "$TOKEN_FILE"
  chmod 600 "$TOKEN_FILE"
  echo "[entrypoint] generated setup-token: cat $TOKEN_FILE"
fi

# Cleanup: delete token-file if users already exist
if [ "$USER_COUNT" -gt "0" ] && [ -f "$TOKEN_FILE" ]; then
  rm -f "$TOKEN_FILE"
fi

# Permissions for Apache
chown -R www-data:www-data "$AUTH_DIR"
chmod 750 "$AUTH_DIR"
chmod 640 "$DB"

# Hand off to Apache
exec apache2-foreground

Edit /home/timbr/0nefinity/Dockerfile — am Ende vor EXPOSE 80 ergänzen:

# sqlite3 CLI für entrypoint + openssl für token-gen (openssl ist meist schon da)
RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/*

# pdo_sqlite für PHP
RUN docker-php-ext-install pdo pdo_sqlite

# Entrypoint
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
cd /home/timbr/0nefinity
docker compose build dev 2>&1 | tail -20

Expected: Build succeeds, kein ERROR. Image wird gebaut, aber Container noch nicht neu gestartet.

Message: feat(docker): entrypoint with setup-token generation + pdo_sqlite


Task 5: .auth/bootstrap.php — central library

Files:

Datei 0nefinity.love/.auth/bootstrap.php:

<?php
declare(strict_types=1);

// Path constants
define('AUTH_DIR', __DIR__);
define('AUTH_DB',  AUTH_DIR . '/auth.sqlite');
define('SETUP_TOKEN_FILE', AUTH_DIR . '/setup-token');
define('CSP_LOG', AUTH_DIR . '/csp-violations.log');

// Argon2id parameters (OWASP 2024+)
define('PW_HASH_OPTS', [
  'memory_cost' => 65536, // 64 MiB
  'time_cost'   => 4,
  'threads'     => 1,
]);
define('PW_MIN_LEN', 12);
define('FIXED_USERNAME', '0nefinity');

// Session config — MUST run before session_start()
function auth_session_start(): void {
  if (session_status() === PHP_SESSION_ACTIVE) return;
  ini_set('session.use_strict_mode', '1');
  ini_set('session.cookie_httponly', '1');
  ini_set('session.cookie_secure',   '1');
  ini_set('session.cookie_samesite', 'Strict');
  ini_set('session.gc_maxlifetime',  '1209600'); // 14 days
  ini_set('session.use_only_cookies','1');

  session_set_cookie_params([
    'lifetime' => 1209600,
    'path'     => '/',
    'secure'   => true,
    'httponly' => true,
    'samesite' => 'Strict',
  ]);
  session_start();
}

// PDO (singleton)
function auth_db(): PDO {
  static $pdo = null;
  if ($pdo === null) {
    $pdo = new PDO('sqlite:' . AUTH_DB);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
    $pdo->exec('PRAGMA foreign_keys = ON;');
  }
  return $pdo;
}

function setup_mode_active(): bool {
  $cnt = (int) auth_db()->query('SELECT COUNT(*) c FROM users')->fetch()['c'];
  return $cnt === 0 && is_file(SETUP_TOKEN_FILE);
}

function require_auth(): array {
  auth_session_start();
  if (empty($_SESSION['uid'])) {
    $next = $_SERVER['REQUEST_URI'] ?? '/admin';
    header('Location: /enternity?next=' . urlencode($next));
    exit;
  }
  $u = auth_db()->prepare('SELECT id, username, role FROM users WHERE id = ?');
  $u->execute([$_SESSION['uid']]);
  $user = $u->fetch();
  if (!$user) {
    session_destroy();
    header('Location: /enternity');
    exit;
  }
  return $user;
}

function client_ip(): string {
  // Behind nginx, RemoteIP module already populates REMOTE_ADDR.
  return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}

function json_response(array $data, int $code = 200): void {
  http_response_code($code);
  header('Content-Type: application/json');
  echo json_encode($data, JSON_UNESCAPED_SLASHES);
  exit;
}
docker exec 0nefinity-dev php -l /var/www/html/.auth/bootstrap.php 2>&1 || \
  php -l /home/timbr/0nefinity/0nefinity.love/.auth/bootstrap.php

Expected: No syntax errors detected

git add .auth/bootstrap.php
git commit -m "feat(auth): bootstrap lib (PDO, session config, require_auth)"

Task 6: .auth/csrf.php — Double-Submit Token

Files:

Datei 0nefinity.love/.auth/csrf.php:

<?php
declare(strict_types=1);

function csrf_token(): string {
  if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
  }
  return $_SESSION['csrf'];
}

function csrf_verify(string $posted): bool {
  if (empty($_SESSION['csrf']) || $posted === '') return false;
  return hash_equals($_SESSION['csrf'], $posted);
}

function csrf_field(): string {
  $t = htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8');
  return '<input type="hidden" name="csrf" value="' . $t . '">';
}
php -l /home/timbr/0nefinity/0nefinity.love/.auth/csrf.php

Expected: No syntax errors detected

git add .auth/csrf.php
git commit -m "feat(auth): csrf double-submit helpers"

Task 7: .auth/hibp.php — HaveIBeenPwned k-anonymity

Files:

Datei 0nefinity.love/.auth/hibp.php:

<?php
declare(strict_types=1);

/**
 * Checks password against HaveIBeenPwned via k-anonymity.
 * Returns count of times pwned (0 = safe).
 * Returns -1 on network failure (caller decides policy — recommend allow with warning).
 */
function hibp_pwned_count(string $password, int $timeout = 5): int {
  $sha1   = strtoupper(sha1($password));
  $prefix = substr($sha1, 0, 5);
  $suffix = substr($sha1, 5);

  $url = "https://api.pwnedpasswords.com/range/$prefix";

  $ch = curl_init($url);
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => $timeout,
    CURLOPT_USERAGENT      => '0nefinity-auth/1.0',
    CURLOPT_HTTPHEADER     => ['Add-Padding: true'],
  ]);
  $body = curl_exec($ch);
  $http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);

  if ($http !== 200 || !is_string($body)) return -1;

  foreach (explode("\n", $body) as $line) {
    $line = trim($line);
    if ($line === '') continue;
    [$tail, $count] = array_pad(explode(':', $line, 2), 2, '0');
    if (strtoupper($tail) === $suffix) {
      return (int) $count;
    }
  }
  return 0;
}
docker exec 0nefinity-dev php -m | grep -i curl

Expected: curl taucht auf. Falls nicht:

php -l /home/timbr/0nefinity/0nefinity.love/.auth/hibp.php

Expected: No syntax errors detected

Quick-test (vor Container-Restart, lokal):

php -r '
require "/home/timbr/0nefinity/0nefinity.love/.auth/hibp.php";
echo "password: " . hibp_pwned_count("password") . "\n";
echo "x7Kp9!mNq3vR2sLb4zY: " . hibp_pwned_count("x7Kp9!mNq3vR2sLb4zY") . "\n";
'

Expected: password: ergibt eine Zahl > 1.000.000. Random-String ergibt 0.

git add .auth/hibp.php
git commit -m "feat(auth): HIBP k-anonymity check"

Task 8: .auth/rate-limit.php — Sliding Window + Exponential Backoff

Files:

Datei 0nefinity.love/.auth/rate-limit.php:

<?php
declare(strict_types=1);

const RL_WINDOW   = 60;   // sliding-window seconds
const RL_MAX_FAILS = 5;   // fails per IP per window

/**
 * Returns ['ok' => bool, 'retry_after' => int seconds].
 * Implements:
 *  - sliding window: max RL_MAX_FAILS in last RL_WINDOW seconds → 429
 *  - exponential backoff: required delay = 2^(consecutive_fails - RL_MAX_FAILS) seconds, capped at 300
 */
function rate_limit_check(string $ip): array {
  $db = auth_db();
  $now = time();
  $cut = $now - RL_WINDOW;

  $st = $db->prepare('SELECT COUNT(*) c FROM login_attempts WHERE ip = ? AND success = 0 AND ts > ?');
  $st->execute([$ip, $cut]);
  $recent_fails = (int) $st->fetch()['c'];

  if ($recent_fails >= RL_MAX_FAILS) {
    // Find most recent fail, compute backoff
    $last = $db->prepare('SELECT ts FROM login_attempts WHERE ip = ? AND success = 0 ORDER BY ts DESC LIMIT 1');
    $last->execute([$ip]);
    $last_ts = (int) ($last->fetch()['ts'] ?? $now);
    $extra = $recent_fails - RL_MAX_FAILS + 1; // 1, 2, 3 ...
    $delay = min(300, (int) pow(2, $extra));  // 2, 4, 8 ... cap 300s
    $retry = max(1, ($last_ts + $delay) - $now);
    return ['ok' => false, 'retry_after' => $retry];
  }
  return ['ok' => true, 'retry_after' => 0];
}

function rate_limit_record(string $ip, ?string $username, bool $success): void {
  $st = auth_db()->prepare('INSERT INTO login_attempts (ip, username, success, ts) VALUES (?, ?, ?, ?)');
  $st->execute([$ip, $username, $success ? 1 : 0, time()]);
}
php -l /home/timbr/0nefinity/0nefinity.love/.auth/rate-limit.php

Expected: No syntax errors detected

git add .auth/rate-limit.php
git commit -m "feat(auth): rate-limit with sliding window + exp backoff"

Task 9: .htaccess Routing-Updates

Files:

Edit .htaccess. Direkt nach dem dot-folder-Block (nach Zeile 4, vor HTTPS-Erzwingung) einfügen:

# /.auth/ niemals ausliefern (defense in depth — auch .auth/.htaccess deniest)
RewriteRule "^\.auth(/|$)" - [F,L]

Direkt nach dem non-www -> www-Redirect (nach Zeile 13) — also vor allen weird-text-viewer und color-Catchalls — einfügen:

# Auth routes (pretty URLs)
RewriteRule ^enternity/?$ /enternity.php [L,QSA]
RewriteRule ^admin/?$ /admin/index.php [L,QSA]
RewriteRule ^admin/(.+)$ /admin/$1 [L,QSA]
RewriteRule ^api/auth/login/?$ /api/auth/login.php [L,QSA]
RewriteRule ^api/auth/logout/?$ /api/auth/logout.php [L,QSA]
RewriteRule ^api/auth/me/?$ /api/auth/me.php [L,QSA]
RewriteRule ^api/auth/setup/?$ /api/auth/setup.php [L,QSA]
RewriteRule ^api/csp-report/?$ /api/csp-report.php [L,QSA]

Wichtig: Diese Block muss vor dem color.php-Catchall (Zeile ~60 alt) stehen, sonst landet /enternity in color.php.

docker exec 0nefinity-dev apachectl -t 2>&1

Expected: Syntax OK

(.htaccess-Files werden bei jedem Request neu geparst, nicht beim Startup — also schreibt der Container das eigentlich nicht out. Trotzdem -t als Smoke-Test der Hauptconfig.)

git add .htaccess
git commit -m "feat(htaccess): routing for /enternity, /admin, /api/auth/*"

Task 10: enternity.php — Login + Setup Form

Files:

Datei 0nefinity.love/enternity.php:

<?php
declare(strict_types=1);
require_once __DIR__ . '/.auth/bootstrap.php';
require_once __DIR__ . '/.auth/csrf.php';

auth_session_start();

// Already logged in? → /admin
if (!empty($_SESSION['uid'])) {
  header('Location: /admin');
  exit;
}

$setup = setup_mode_active();
$next  = $_GET['next'] ?? '/admin';
$next  = preg_match('#^/[a-zA-Z0-9/_-]*$#', $next) ? $next : '/admin';

$csrf = csrf_token();
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title><?= $setup ? 'Setup' : 'Enter' ?> 0nefinity</title>
  <link rel="stylesheet" href="/meta.css">
  <style>
    .enter-wrap { display:flex; min-height:100svh; align-items:center; justify-content:center; padding:4vw; }
    .enter-card { max-width:360px; width:100%; padding:2rem; border:1px solid var(--text-color); border-radius:4px; }
    .enter-card h1 { margin:0 0 .5rem; }
    .enter-card .sub { opacity:.7; font-size:.875rem; margin-bottom:1.5rem; }
    .enter-card label { display:block; margin-top:1rem; font-size:.875rem; }
    .enter-card input[type=text], .enter-card input[type=password] {
      width:100%; padding:12px; font-size:16px; min-height:44px;
      background:transparent; color:var(--text-color);
      border:1px solid var(--text-color); border-radius:2px;
      box-sizing:border-box;
    }
    .pw-row { display:flex; gap:.25rem; align-items:stretch; }
    .pw-row input { flex:1; }
    .eye-btn {
      min-width:44px; min-height:44px; padding:0;
      background:transparent; color:var(--text-color);
      border:1px solid var(--text-color); border-radius:2px;
      cursor:pointer; font-size:1rem;
    }
    .eye-btn:focus-visible { outline:2px solid var(--text-color-hover, var(--text-color)); }
    .submit-btn {
      width:100%; margin-top:1.25rem; min-height:44px;
      background:var(--text-color); color:var(--bg-color);
      border:0; border-radius:2px; cursor:pointer; font-size:1rem;
    }
    .submit-btn:disabled { opacity:.5; cursor:wait; }
    .error-slot { min-height:1.5rem; margin-top:1rem; font-size:.875rem; color:#ff4040; }
  </style>
</head>
<body>
  <div class="enter-wrap">
    <form class="enter-card" id="enter-form"
          method="post"
          action="<?= $setup ? '/api/auth/setup' : '/api/auth/login' ?>">
      <?php if ($setup): ?>
        <h1>Setup 0nefinity</h1>
        <p class="sub">First run. Read your setup-token via<br><code>docker exec 0nefinity-live cat /app/.auth/setup-token</code></p>
        <?= csrf_field() ?>
        <label>Username
          <input type="text" name="username" value="<?= htmlspecialchars(FIXED_USERNAME) ?>" readonly autocomplete="username">
        </label>
        <label>Setup token
          <input type="text" name="token" autocomplete="off" required>
        </label>
        <label>New password (min <?= PW_MIN_LEN ?> chars)
          <div class="pw-row">
            <input type="password" name="password" id="pw" autocomplete="new-password" required minlength="<?= PW_MIN_LEN ?>">
            <button type="button" class="eye-btn" id="eye" aria-label="show password">eye</button>
          </div>
        </label>
        <label>Confirm password
          <input type="password" name="password_confirm" autocomplete="new-password" required minlength="<?= PW_MIN_LEN ?>">
        </label>
        <button type="submit" class="submit-btn">Setup</button>
      <?php else: ?>
        <h1>Enter 0nefinity</h1>
        <p class="sub">Enter username and pw and press enter to enter 0nefinity.</p>
        <?= csrf_field() ?>
        <input type="hidden" name="next" value="<?= htmlspecialchars($next) ?>">
        <label>Username
          <input type="text" name="username" autocomplete="username" required>
        </label>
        <label>Password
          <div class="pw-row">
            <input type="password" name="password" id="pw" autocomplete="current-password" required>
            <button type="button" class="eye-btn" id="eye" aria-label="show password">eye</button>
          </div>
        </label>
        <button type="submit" class="submit-btn">Enter</button>
      <?php endif; ?>
      <div class="error-slot" id="err" role="alert"></div>
    </form>
  </div>

<script>
  const eye = document.getElementById('eye');
  const pw  = document.getElementById('pw');
  eye?.addEventListener('click', () => {
    pw.type = pw.type === 'password' ? 'text' : 'password';
    eye.textContent = pw.type === 'password' ? 'eye' : 'hide';
  });

  const form = document.getElementById('enter-form');
  const err  = document.getElementById('err');
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    err.textContent = '';
    const btn = form.querySelector('.submit-btn');
    btn.disabled = true;
    const fd = new FormData(form);
    const body = Object.fromEntries(fd.entries());
    try {
      const r = await fetch(form.action, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(body),
      });
      const j = await r.json().catch(() => ({}));
      if (r.ok && j.ok) {
        window.location.href = j.redirect || '/admin';
        return;
      }
      err.textContent = j.error || 'Falsche Logindaten';
    } catch (ex) {
      err.textContent = 'Netzwerk-Fehler';
    } finally {
      btn.disabled = false;
    }
  });
</script>
</body>
</html>
php -l /home/timbr/0nefinity/0nefinity.love/enternity.php

Expected: No syntax errors detected

git add enternity.php
git commit -m "feat(enternity): login + setup form UI"

Task 11: api/auth/setup.php — Setup-Endpoint

Files:

Datei 0nefinity.love/api/auth/setup.php:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../../.auth/bootstrap.php';
require_once __DIR__ . '/../../.auth/csrf.php';
require_once __DIR__ . '/../../.auth/hibp.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  json_response(['error' => 'method_not_allowed'], 405);
}

auth_session_start();

$raw = file_get_contents('php://input');
$in  = json_decode($raw, true) ?: [];

if (!csrf_verify($in['csrf'] ?? '')) {
  json_response(['error' => 'csrf'], 403);
}

if (!setup_mode_active()) {
  json_response(['error' => 'setup_disabled'], 403);
}

$token = (string)($in['token'] ?? '');
$pw    = (string)($in['password'] ?? '');
$pw2   = (string)($in['password_confirm'] ?? '');

// Read token-file
$expected = @file_get_contents(SETUP_TOKEN_FILE);
if (!is_string($expected) || $expected === '') {
  json_response(['error' => 'no_setup_token'], 403);
}
$expected = trim($expected);

if (!hash_equals($expected, $token)) {
  // No rate-limit during setup intentionally — token has 256-bit entropy
  json_response(['error' => 'bad_token'], 401);
}

if ($pw !== $pw2) {
  json_response(['error' => 'password_mismatch'], 400);
}

if (strlen($pw) < PW_MIN_LEN) {
  json_response(['error' => 'password_too_short', 'min' => PW_MIN_LEN], 400);
}

$hibp = hibp_pwned_count($pw);
if ($hibp > 0) {
  json_response(['error' => 'password_pwned', 'count' => $hibp], 400);
}
// hibp_pwned_count == -1 → network failure, allow with no warning (Phase 1 pragmatic)

$hash = password_hash($pw, PASSWORD_ARGON2ID, PW_HASH_OPTS);
$now  = time();

$db = auth_db();
$db->beginTransaction();
try {
  $st = $db->prepare('INSERT INTO users (username, password_hash, role, created_at, last_login_at) VALUES (?, ?, "admin", ?, ?)');
  $st->execute([FIXED_USERNAME, $hash, $now, $now]);
  $uid = (int) $db->lastInsertId();
  $db->commit();
} catch (Throwable $e) {
  $db->rollBack();
  json_response(['error' => 'db_error'], 500);
}

// Delete setup-token (one-time use)
@unlink(SETUP_TOKEN_FILE);

// Session
session_regenerate_id(true);
$_SESSION['uid'] = $uid;

json_response(['ok' => true, 'redirect' => '/admin']);
php -l /home/timbr/0nefinity/0nefinity.love/api/auth/setup.php

Expected: No syntax errors detected

git add api/auth/setup.php
git commit -m "feat(api): setup endpoint with token + HIBP + Argon2id"

Task 12: api/auth/login.php — Login-Endpoint

Files:

Datei 0nefinity.love/api/auth/login.php:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../../.auth/bootstrap.php';
require_once __DIR__ . '/../../.auth/csrf.php';
require_once __DIR__ . '/../../.auth/rate-limit.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  json_response(['error' => 'method_not_allowed'], 405);
}

auth_session_start();

$raw = file_get_contents('php://input');
$in  = json_decode($raw, true) ?: [];

if (!csrf_verify($in['csrf'] ?? '')) {
  json_response(['error' => 'csrf'], 403);
}

$username = trim((string)($in['username'] ?? ''));
$password = (string)($in['password'] ?? '');
$next     = (string)($in['next'] ?? '/admin');
if (!preg_match('#^/[a-zA-Z0-9/_-]*$#', $next)) $next = '/admin';

$ip = client_ip();

// Rate-limit check
$rl = rate_limit_check($ip);
if (!$rl['ok']) {
  header('Retry-After: ' . $rl['retry_after']);
  json_response(['error' => 'rate_limited', 'retry_after' => $rl['retry_after']], 429);
}

$db = auth_db();
$st = $db->prepare('SELECT id, password_hash, role FROM users WHERE username = ?');
$st->execute([$username]);
$row = $st->fetch();

// Constant-time: even if user doesn't exist, run a password_verify against a dummy hash
$DUMMY = '$argon2id$v=19$m=65536,t=4,p=1$ZHVtbXkx$dW1teWR1bW15ZHVtbXlkdW1teWR1bW15ZHVtbXlkdW1teWR1bW0';
$ok = password_verify($password, $row['password_hash'] ?? $DUMMY) && $row;

if (!$ok) {
  rate_limit_record($ip, $username, false);
  // Anti-timing sleep: small extra jitter
  usleep(random_int(50_000, 250_000));
  json_response(['error' => 'invalid_credentials'], 401);
}

// Success
rate_limit_record($ip, $username, true);
session_regenerate_id(true);
$_SESSION['uid'] = (int) $row['id'];

// update last_login_at
$db->prepare('UPDATE users SET last_login_at = ? WHERE id = ?')->execute([time(), (int)$row['id']]);

json_response(['ok' => true, 'redirect' => $next]);
php -l /home/timbr/0nefinity/0nefinity.love/api/auth/login.php

Expected: No syntax errors detected

git add api/auth/login.php
git commit -m "feat(api): login endpoint with rate-limit + constant-time verify"

Task 13: api/auth/logout.php + api/auth/me.php

Files:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../../.auth/bootstrap.php';
require_once __DIR__ . '/../../.auth/csrf.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  json_response(['error' => 'method_not_allowed'], 405);
}
auth_session_start();

$raw = file_get_contents('php://input');
$in  = json_decode($raw, true) ?: [];
if (!csrf_verify($in['csrf'] ?? '')) {
  json_response(['error' => 'csrf'], 403);
}

$_SESSION = [];
if (ini_get('session.use_cookies')) {
  $p = session_get_cookie_params();
  setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
json_response(['ok' => true, 'redirect' => '/enternity']);
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../.auth/bootstrap.php';

auth_session_start();
if (empty($_SESSION['uid'])) {
  json_response(['error' => 'unauthenticated'], 401);
}
$st = auth_db()->prepare('SELECT username, role FROM users WHERE id = ?');
$st->execute([$_SESSION['uid']]);
$u = $st->fetch();
if (!$u) {
  json_response(['error' => 'unauthenticated'], 401);
}
json_response(['user' => $u]);
php -l /home/timbr/0nefinity/0nefinity.love/api/auth/logout.php
php -l /home/timbr/0nefinity/0nefinity.love/api/auth/me.php

Expected: No syntax errors detected (beide)

git add api/auth/logout.php api/auth/me.php
git commit -m "feat(api): logout + me endpoints"

Task 14: /admin/index.php — Auth-Guarded Stub

Files:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../.auth/bootstrap.php';
require_once __DIR__ . '/../.auth/csrf.php';

$user = require_auth();
$csrf = csrf_token();
?>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>0nefinity admin</title>
  <link rel="stylesheet" href="/meta.css">
  <style>
    .admin-wrap { padding:2rem; max-width:960px; margin:0 auto; }
    .admin-bar { display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--text-color); padding-bottom:1rem; }
    .admin-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:1rem; margin-top:2rem; }
    .admin-card { border:1px solid var(--text-color); padding:1.5rem; border-radius:4px; }
    .admin-card h2 { margin:0 0 .5rem; font-size:1.1rem; }
    .admin-card p { margin:0; opacity:.6; font-size:.875rem; }
    .logout-btn { background:transparent; border:1px solid var(--text-color); color:var(--text-color); padding:.5rem 1rem; cursor:pointer; border-radius:2px; min-height:40px; }
  </style>
</head>
<body>
<div class="admin-wrap">
  <div class="admin-bar">
    <strong>0nefinity admin</strong>
    <button class="logout-btn" id="logout">logout</button>
  </div>
  <p style="margin-top:1.5rem;">Welcome, <?= htmlspecialchars($user['username']) ?>.</p>
  <div class="admin-grid">
    <div class="admin-card"><h2>Analytics</h2><p>(coming soon — Phase 2: Umami embed)</p></div>
    <div class="admin-card"><h2>Users</h2><p>(coming soon — Phase 2: invite + roles)</p></div>
    <div class="admin-card"><h2>Settings</h2><p>(coming soon)</p></div>
  </div>
</div>
<script>
  document.getElementById('logout').addEventListener('click', async () => {
    const r = await fetch('/api/auth/logout', {
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify({csrf: <?= json_encode($csrf) ?>}),
    });
    const j = await r.json().catch(()=>({}));
    window.location.href = j.redirect || '/enternity';
  });
</script>
</body>
</html>
php -l /home/timbr/0nefinity/0nefinity.love/admin/index.php

Expected: No syntax errors detected

git add admin/index.php
git commit -m "feat(admin): auth-guarded stub dashboard with logout"

Task 15: api/csp-report.php — CSP Violations Sink

Files:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../.auth/bootstrap.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
  http_response_code(204);
  exit;
}

$body = file_get_contents('php://input');
if (!is_string($body) || $body === '') {
  http_response_code(204);
  exit;
}

// Cap to prevent log-flood
$body = substr($body, 0, 8192);
$entry = json_encode([
  'ts' => date('c'),
  'ip' => client_ip(),
  'ua' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 256),
  'report' => json_decode($body, true) ?: $body,
]) . "\n";
@file_put_contents(CSP_LOG, $entry, FILE_APPEND | LOCK_EX);
http_response_code(204);
php -l /home/timbr/0nefinity/0nefinity.love/api/csp-report.php

Expected: No syntax errors detected

git add api/csp-report.php
git commit -m "feat(api): csp-report-only sink"

Task 16: nginx CSP-Header (report-only)

Files:

sudo cp /etc/nginx/sites-enabled/0nefinity-live /etc/nginx/sites-enabled/0nefinity-live.bak-$(date +%F)

Sudo-Edit der nginx-Config — im server { … } block für 0nefinity.love (port 443) folgenden Header ergänzen (zu den bestehenden add_header-Zeilen):

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:; report-uri /api/csp-report" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
sudo nginx -t

Expected: syntax is ok und test is successful. Falls Fehler: Backup zurückspielen, Fehler fixen.

sudo systemctl reload nginx

Expected: kein Output, exit 0.

Hinweis: Dieser Schritt ist Host-Side, nicht im Git-Repo. Kein Commit.


Task 17: tools/auth-cli.php — CLI für Reset + Invite-Stub

Files:

<?php
declare(strict_types=1);

if (php_sapi_name() !== 'cli') {
  http_response_code(403);
  exit('CLI-only.');
}

require_once __DIR__ . '/../.auth/bootstrap.php';
require_once __DIR__ . '/../.auth/hibp.php';

$argv = $_SERVER['argv'];
$cmd  = $argv[1] ?? '';

function prompt_hidden(string $label): string {
  echo $label;
  if (preg_match('/^win/i', PHP_OS)) {
    return trim((string) fgets(STDIN));
  }
  system('stty -echo');
  $line = trim((string) fgets(STDIN));
  system('stty echo');
  echo "\n";
  return $line;
}

switch ($cmd) {
  case 'set-password': {
    $username = $argv[2] ?? '';
    if ($username === '') { fwrite(STDERR, "Usage: set-password <username>\n"); exit(2); }
    $st = auth_db()->prepare('SELECT id FROM users WHERE username = ?');
    $st->execute([$username]);
    $u = $st->fetch();
    if (!$u) { fwrite(STDERR, "User not found.\n"); exit(3); }
    $pw  = prompt_hidden("New password (min " . PW_MIN_LEN . "): ");
    $pw2 = prompt_hidden("Confirm: ");
    if ($pw !== $pw2) { fwrite(STDERR, "Mismatch.\n"); exit(4); }
    if (strlen($pw) < PW_MIN_LEN) { fwrite(STDERR, "Too short.\n"); exit(5); }
    $hibp = hibp_pwned_count($pw);
    if ($hibp > 0) { fwrite(STDERR, "Password seen in HIBP ($hibp times). Refusing.\n"); exit(6); }
    $hash = password_hash($pw, PASSWORD_ARGON2ID, PW_HASH_OPTS);
    auth_db()->prepare('UPDATE users SET password_hash = ? WHERE id = ?')->execute([$hash, $u['id']]);
    echo "Password updated for $username.\n";
    break;
  }

  case 'list-users': {
    $rows = auth_db()->query('SELECT id, username, role, email, created_at, last_login_at FROM users')->fetchAll();
    foreach ($rows as $r) {
      $created = date('c', (int)$r['created_at']);
      $last    = $r['last_login_at'] ? date('c', (int)$r['last_login_at']) : 'never';
      echo "#{$r['id']}  {$r['username']}  role={$r['role']}  email=" . ($r['email'] ?? '-') . "  created=$created  last_login=$last\n";
    }
    break;
  }

  case 'create-invite': {
    // Phase 2 stub — schema is ready, generates invite link
    $email = null;
    foreach ($argv as $a) {
      if (str_starts_with($a, '--email=')) $email = substr($a, 8);
    }
    $token = bin2hex(random_bytes(32));
    $hash  = hash('sha256', $token);
    $now   = time();
    $exp   = $now + 7 * 86400;
    auth_db()->prepare('INSERT INTO invite_tokens (token_hash, email, created_at, expires_at) VALUES (?, ?, ?, ?)')
             ->execute([$hash, $email, $now, $exp]);
    echo "Invite URL (Phase 2): https://0nefinity.love/enternity?invite=$token\n";
    echo "Expires: " . date('c', $exp) . "\n";
    break;
  }

  default:
    fwrite(STDERR, "Commands: set-password <username> | list-users | create-invite [--email=foo]\n");
    exit(1);
}
php -l /home/timbr/0nefinity/0nefinity.love/tools/auth-cli.php

Expected: No syntax errors detected

git add tools/auth-cli.php
git commit -m "feat(cli): auth-cli (set-password, list-users, create-invite stub)"

Task 18: Container rebuild + restart (dev)

Files: keine.

cd /home/timbr/0nefinity
docker compose build dev
docker compose up -d dev
docker compose logs dev | tail -30

Expected: Log zeigt [entrypoint] initializing auth.sqlite und [entrypoint] generated setup-token. Apache started.

docker exec 0nefinity-dev cat /var/www/html/.auth/setup-token

Expected: 64-Zeichen Hex-String.

docker exec 0nefinity-dev ls -la /var/www/html/.auth/

Expected: auth.sqlite, setup-token, schema.sql, bootstrap.php, csrf.php, hibp.php, rate-limit.php, .htaccess


Task 19: Smoke-Tests (curl) auf dev.0nefinity.love

Files: keine, nur Verifikation.

curl -s -o /dev/null -w "%{http_code}\n" https://dev.0nefinity.love/.auth/auth.sqlite
curl -s -o /dev/null -w "%{http_code}\n" https://dev.0nefinity.love/.auth/setup-token
curl -s -o /dev/null -w "%{http_code}\n" https://dev.0nefinity.love/.auth/bootstrap.php

Expected: alle drei 403.

curl -s https://dev.0nefinity.love/enternity | grep -E '(Setup 0nefinity|Setup token)'

Expected: beide Strings tauchen auf (Setup-Modus aktiv).

curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" https://dev.0nefinity.love/admin

Expected: 302 mit Redirect zu /enternity?next=%2Fadmin.

(Setup ist noch nicht durch, also User existiert noch nicht. Login muss trotzdem rate-limit + 401 liefern, nicht crashen.)

# CSRF holen via Cookie-Session
COOKIE=/tmp/0nefinity-cookies.txt
curl -s -c $COOKIE https://dev.0nefinity.love/enternity > /dev/null
CSRF=$(curl -s -b $COOKIE https://dev.0nefinity.love/enternity | grep -oP 'name="csrf" value="\K[^"]+')
echo "CSRF: $CSRF"

curl -s -b $COOKIE -X POST https://dev.0nefinity.love/api/auth/login \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"0nefinity\",\"password\":\"wrong\",\"csrf\":\"$CSRF\"}" \
  -w "\nstatus:%{http_code}\n"

Expected: {"error":"invalid_credentials"} status:401

TOKEN=$(docker exec 0nefinity-dev cat /var/www/html/.auth/setup-token)
echo "TOKEN: $TOKEN"

# fresh session for setup
COOKIE=/tmp/0nefinity-cookies.txt
rm -f $COOKIE
curl -s -c $COOKIE https://dev.0nefinity.love/enternity > /dev/null
CSRF=$(curl -s -b $COOKIE https://dev.0nefinity.love/enternity | grep -oP 'name="csrf" value="\K[^"]+')

# Use a strong PW that is NOT in HIBP
PW="zX9!muN3p4Wq7vRkLb2YsTc8aQ"

curl -s -b $COOKIE -c $COOKIE -X POST https://dev.0nefinity.love/api/auth/setup \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"0nefinity\",\"token\":\"$TOKEN\",\"password\":\"$PW\",\"password_confirm\":\"$PW\",\"csrf\":\"$CSRF\"}" \
  -w "\nstatus:%{http_code}\n"

Expected: {"ok":true,"redirect":"/admin"} status:200.

docker exec 0nefinity-dev ls /var/www/html/.auth/setup-token 2>&1

Expected: No such file or directory.

curl -s -b $COOKIE https://dev.0nefinity.love/api/auth/me

Expected: {"user":{"username":"0nefinity","role":"admin"}}

curl -s -b $COOKIE -o /dev/null -w "%{http_code}\n" https://dev.0nefinity.love/admin

Expected: 200.

COOKIE2=/tmp/0nefinity-cookies2.txt
rm -f $COOKIE2
curl -s -c $COOKIE2 https://dev.0nefinity.love/enternity > /dev/null
CSRF=$(curl -s -b $COOKIE2 https://dev.0nefinity.love/enternity | grep -oP 'name="csrf" value="\K[^"]+')

curl -s -b $COOKIE2 -c $COOKIE2 -X POST https://dev.0nefinity.love/api/auth/login \
  -H 'Content-Type: application/json' \
  -d "{\"username\":\"0nefinity\",\"password\":\"$PW\",\"csrf\":\"$CSRF\",\"next\":\"/admin\"}" \
  -w "\nstatus:%{http_code}\n"

Expected: {"ok":true,"redirect":"/admin"} status:200.

CSRF=$(curl -s -b $COOKIE2 https://dev.0nefinity.love/admin | grep -oP 'json_encode\("\K[^"]+' | head -1)
# Falls grep nicht greift, manuell: server-side csrf bleibt gleich pro session
# Easier: use the same CSRF from before login (CSRF survives in session)

curl -s -b $COOKIE2 -X POST https://dev.0nefinity.love/api/auth/logout \
  -H 'Content-Type: application/json' \
  -d "{\"csrf\":\"$CSRF\"}" \
  -w "\nstatus:%{http_code}\n"

Expected: {"ok":true,"redirect":"/enternity"} status:200.

COOKIE=/tmp/rl-cookies.txt
rm -f $COOKIE
curl -s -c $COOKIE https://dev.0nefinity.love/enternity > /dev/null
CSRF=$(curl -s -b $COOKIE https://dev.0nefinity.love/enternity | grep -oP 'name="csrf" value="\K[^"]+')

for i in 1 2 3 4 5 6; do
  curl -s -b $COOKIE -X POST https://dev.0nefinity.love/api/auth/login \
    -H 'Content-Type: application/json' \
    -d "{\"username\":\"0nefinity\",\"password\":\"wrong$i\",\"csrf\":\"$CSRF\"}" \
    -w "  attempt $i: %{http_code}\n" \
    -o /dev/null
done

Expected: Attempts 1-5 → 401, Attempt 6 → 429.

curl -s -b $COOKIE -X POST https://dev.0nefinity.love/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"0nefinity","password":"x","csrf":"BAD"}' \
  -w "\nstatus:%{http_code}\n"

Expected: {"error":"csrf"} status:403.


Task 20: Manuelle Browser-Walkthrough (Mobile + Desktop)

Files: keine, nur Verifikation.

docker exec 0nefinity-dev sqlite3 /var/www/html/.auth/auth.sqlite "DELETE FROM users; DELETE FROM login_attempts;"
docker restart 0nefinity-dev
sleep 2
docker exec 0nefinity-dev cat /var/www/html/.auth/setup-token

(Token kopieren für Browser-Test.)

Tim öffnet https://dev.0nefinity.love/enternity und prüft:

Tim öffnet auf Phone (oder DevTools-Mobile-Mode):

docker exec 0nefinity-dev cat /var/www/html/.auth/csp-violations.log 2>/dev/null || echo "noch keine"

Erwartung: Falls inline-scripts CSP-violaten, sind sie hier geloggt. Phase 2 wird das für Hardening genutzt.


Task 21: Tracking-Snippet-Vorbereitung

Files: keine konkrete Änderung jetzt, aber Notiz im Plan.

Tim hat Umami als Tracking-Lösung erwähnt. Vorbereitung:

(Keine Code-Änderung in Phase 1.)


Task 22: PR auf main + Merge + Deploy live

Files: keine.

cd /home/timbr/0nefinity/0nefinity.love
git status

Expected: nothing to commit, working tree clean.

git push origin feat/enternity-login
gh pr create --title "feat: enternity login (Phase 1)" --body "$(cat <<'EOF'
## Summary
- Phase 1 of /enternity login (single user `0nefinity`)
- Setup-Token-Flow for initial password setup
- Argon2id (OWASP 2024+ params), HIBP-Check, NIST 800-63B-3 password policy
- Rate-limiting (5 fails/IP/min + exp backoff), CSRF double-submit
- Session cookies: HttpOnly+Secure+SameSite=Strict, 14d lifetime
- /admin stub dashboard + logout
- CSP report-only header

Spec: docs/specs/2026-05-12-enternity-login-design.md
Plan: docs/plans/2026-05-12-enternity-login-plan.md

## Test plan
- [x] Smoke-Tests via curl on dev.0nefinity.love (Task 19)
- [x] Browser walkthrough Desktop + Mobile (Task 20)
- [x] Setup-token flow
- [x] Login + logout + rate-limit + CSRF
- [x] .auth/ web-blocked

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Expected: PR-URL wird ausgegeben.

Manuell. Tim merged via GitHub-UI ODER:

gh pr merge --merge
cd /home/timbr/0nefinity
git -C 0nefinity.love pull origin main
docker compose build live
docker compose down live
docker compose up -d live
docker compose logs live | tail -30

Expected: Log zeigt entrypoint-Output, kein Error.

docker exec 0nefinity-live cat /var/www/html/.auth/setup-token

Tim öffnet https://0nefinity.love/enternity, gibt Token + neues PW ein, fertig.

Tim's Backup-Script (Pfad: vermutlich ~/scripts/backup.sh oder restic-config) muss um den Volume-Pfad ergänzt werden. Ohne diesen Schritt geht der auth.sqlite-State beim Volume-Loss verloren.

# Volume-Pfad lokalisieren
docker volume inspect 0nefinity_0nefinity-auth-live --format '{{.Mountpoint}}'

Diesen Pfad zur Backup-Liste hinzufügen (manuelle Aktion). Tim macht das selbst (siehe Memory feedback_tim_triggers_backup_runs.md).


Self-Review-Checkliste

Nach Plan-Schreiben durchgegangen:

1. Spec-Coverage:

2. Placeholder-Scan: Keine TBD/TODO/"similar to" gefunden. Alle Code-Steps haben vollständigen Code.

3. Type-Konsistenz:

Plan ist konsistent. Ready für Execution.