# Secrets Module — src-vault credential management (ADR-017)
# =============================================================
# All operations read credentials from ~/.config/ontoref/vaults/<vault_id>/access.sops.yaml,
# decrypted by the .kage master key resolved from project.ncl::sops.master_key_path
# (per-project override) or ~/.config/ontoref/config.ncl::vault.master_key_path (global).
# Cosign keypair for vault signing/verification lives in ~/.config/ontoref/config.ncl::vault.cosign.
# Direct restic/kopia or sops invocations bypass lock and audit — use these recipes instead.
#
# Common variable loading lives in justfiles/_secrets_lib.sh. Each recipe sources it via
# `{{source_directory()}}/_secrets_lib.sh` to get a consistent, validated env.

# Generate a new age keypair for a role. Prints public + private to stdout.
secrets-gen-key role:
    #!/usr/bin/env bash
    set -euo pipefail
    which age-keygen > /dev/null || { echo "age-keygen not found — install age"; exit 1; }
    echo "Generating age keypair for role: {{role}}"
    age-keygen

# Bootstrap the vault for a new project. Admin-only.
# Collects all recipient public keys, creates access.sops.yaml encrypted for all recipients,
# initializes the local restic/kopia repo. Run secrets-push afterward to upload to ZOT.
#
# Prerequisites:
#   - All recipient age public keys collected (one per role)
#   - Admin .kage available at master_key_path
#   - SOPS_AGE_RECIPIENTS set to comma-separated list of all recipient age public keys
secrets-bootstrap:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    mkdir -p "$VAULT_DIR/logs"

    # Per-file recipient routing — when project declares recipient_rules, generate
    # <vault_dir>/.sops.yaml so sops uses different recipient unions per file path.
    # Otherwise, fall back to SOPS_AGE_RECIPIENTS env var (legacy single-set mode).
    RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
    GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
    HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')
    SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"

    if [[ "$HAS_RULES" == "true" ]]; then
        # ADR-019 coherency check — every group referenced by a rule MUST exist
        # as a key in recipient_groups, AND every recipient_group MUST resolve to
        # at least one non-empty age public key. A typo or empty group produces
        # an unreadable .sops.yaml — better to fail loud at bootstrap.
        UNKNOWN_GROUPS=$(echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
            [.[] | .groups[] | . as $g | select($groups | has($g) | not)] | unique | join(", ")
        ')
        if [[ -n "$UNKNOWN_GROUPS" ]]; then
            echo "[recipient-rules] groups referenced in recipient_rules but missing from recipient_groups: $UNKNOWN_GROUPS" >&2
            echo "  → declare them in project.ncl::sops.recipient_groups, or fix the typo in recipient_rules[].groups" >&2
            exit 1
        fi
        EMPTY_GROUPS=$(echo "$GROUPS_JSON" | jq -r '
            to_entries | map(select(.value | length == 0) | .key) | join(", ")
        ')
        if [[ -n "$EMPTY_GROUPS" ]]; then
            echo "[recipient-rules] recipient_groups with no age keys: $EMPTY_GROUPS" >&2
            echo "  → populate them in project.ncl::sops.recipient_groups, or remove the unused group(s)" >&2
            exit 1
        fi
        UNREFERENCED_GROUPS=$(jq -nrc \
            --argjson rules "$RULES_JSON" --argjson groups "$GROUPS_JSON" '
            ($groups | keys) - ($rules | map(.groups[]) | unique) | join(", ")
        ')
        if [[ -n "$UNREFERENCED_GROUPS" ]]; then
            echo "[recipient-rules] WARN: recipient_groups declared but never used by any rule: $UNREFERENCED_GROUPS" >&2
        fi

        # Resolve groups → age list per rule, write .sops.yaml
        echo "creation_rules:" > "$SOPS_CFG_PATH"
        # Use YAML single-quoted strings for path_regex so backslash escapes
        # like \\. pass through to sops without YAML interpreting them.
        echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
            .[] | "  - path_regex: '"'"'\(.path)'"'"'\n    age: " +
            ([.groups[] | $groups[.] // []] | flatten | unique | join(","))
        ' >> "$SOPS_CFG_PATH"
        echo "Generated $SOPS_CFG_PATH with $(echo "$RULES_JSON" | jq length) rule(s)."
        # When sops sees --config, it uses it; we don't need --age flag.
        SOPS_ENC_FLAGS=(--config "$SOPS_CFG_PATH")
    else
        [[ -n "${SOPS_AGE_RECIPIENTS:-}" ]] || {
            echo "SOPS_AGE_RECIPIENTS not set — export age public keys, OR declare sops.recipient_groups+rules in project.ncl"
            exit 1
        }
        SOPS_ENC_FLAGS=(--age "$SOPS_AGE_RECIPIENTS")
    fi

    if [[ -f "$ACCESS_SOPS" ]]; then
        echo "access.sops.yaml already exists at $ACCESS_SOPS — skipping creation"
    else
        echo "Creating access.sops.yaml. Required inputs:"
        echo "  zot_username + zot_password : ZOT registry credentials for src-vault/<id> namespace"
        echo "  vault_key                    : restic/kopia repo password"
        echo "  cosign_password              : password for vault.cosign.key_path (used by 'secrets push' to sign)"
        echo
        read -r -p "zot_username: " ZOT_USER
        read -r -s -p "zot_password: " ZOT_PASS; echo
        read -r -s -p "vault_key (restic/kopia password): " VAULT_KEY; echo
        read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo

        printf 'zot_username: %s\nzot_password: %s\nvault_key: %s\ncosign_password: %s\n' \
            "$ZOT_USER" "$ZOT_PASS" "$VAULT_KEY" "$COSIGN_PWD" \
            | SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
                --filename-override "$ACCESS_SOPS" \
                --input-type yaml --encrypt /dev/stdin > "$ACCESS_SOPS"
        unset ZOT_USER ZOT_PASS VAULT_KEY COSIGN_PWD
        echo "access.sops.yaml created and encrypted for all recipients"
    fi

    REPO_PATH="$VAULT_DIR/repo"
    if [[ -d "$REPO_PATH" ]]; then
        echo "Vault repo already exists at $REPO_PATH — skipping init"
    else
        CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
        VAULT_KEY_VAL=$(echo "$CREDS" | grep vault_key | awk '{print $2}')
        case "$VAULT_TOOL" in
            restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$REPO_PATH" init ;;
            kopia)  KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia repository create filesystem --path "$REPO_PATH" ;;
        esac
        unset VAULT_KEY_VAL CREDS
        echo "Vault repo initialized at $REPO_PATH"
    fi

    # Initial audit entry — ensures access.jsonl exists for the first secrets-push.
    # Subsequent operations append.
    if [[ ! -f "$VAULT_DIR/logs/access.jsonl" ]]; then
        printf '{"ts":"%s","actor":"%s","op":"bootstrap","detail":"vault initialized"}\n' \
            "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" \
            > "$VAULT_DIR/logs/access.jsonl"
    fi

    # ── Layer 2 (ADR-017) — scopes + registry credentials inside src-vault/ ──
    # The src-vault/ subtree IS the OCI artifact contents. domain_client.nu reads
    # registry/{ro,rw}.sops.yaml via credential_sops/_rw paths from the manifest.
    SRC_VAULT_DIR="$VAULT_DIR/src-vault"
    mkdir -p "$SRC_VAULT_DIR/scopes" "$SRC_VAULT_DIR/registry"

    # Generate scope files for each unique role declared in actor_key_bindings.
    # Default scopes by role-name convention; edit later via 'ore secrets open'.
    ROLES=$(echo "$PROJECT_JSON" | jq -r '[.sops.actor_key_bindings | .[]] | unique | .[]')
    for ROLE in $ROLES; do
        SCOPE_FILE="$SRC_VAULT_DIR/scopes/${ROLE}.ncl"
        if [[ -f "$SCOPE_FILE" ]]; then continue; fi
        # RW for human-operator roles, RO for automation roles. Convention only.
        case "$ROLE" in
            admin|developer|jesus|supervisor)
                ACCESS="'rw"
                OPS="['pull, 'push, 'verify, 'list]"
                ;;
            *)
                ACCESS="'ro"
                OPS="['pull, 'verify, 'list]"
                ;;
        esac
        printf '{\n  role        = "%s",\n  access      = %s,\n  bound_actor = [],\n  namespaces  = ["domains/*/", "modes/*/"],\n  ops         = %s,\n}\n' \
            "$ROLE" "$ACCESS" "$OPS" > "$SCOPE_FILE"
        echo "Created scope: scopes/${ROLE}.ncl"
    done

    # Default Layer 2 credentials: registry/ro.sops.yaml + registry/rw.sops.yaml.
    # Skipped in declarative mode (recipient_rules declared) — the operator names
    # files per their tenancy scheme (e.g. clientA-ro, agent-readonly) and
    # populates them via 'ore secrets open' after bootstrap. Legacy mode keeps
    # the default ro/rw pair seeded with the same zot credentials for both.
    if [[ "$HAS_RULES" == "true" ]]; then
        echo "Skipping default registry/ro|rw.sops.yaml — declarative recipient_rules in effect."
        echo "Populate registry/<file>.sops.yaml entries that match your declared rules."
    else
        L2_CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
        L2_USER=$(echo "$L2_CREDS" | grep '^zot_username:' | awk '{print $2}')
        L2_PASS=$(echo "$L2_CREDS" | grep '^zot_password:' | awk '{print $2}')
        for KIND in ro rw; do
            SOPS_FILE="$SRC_VAULT_DIR/registry/${KIND}.sops.yaml"
            if [[ -f "$SOPS_FILE" ]]; then continue; fi
            printf 'username: %s\npassword: %s\n' "$L2_USER" "$L2_PASS" \
                | SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "${SOPS_ENC_FLAGS[@]}" \
                    --input-type yaml --encrypt /dev/stdin > "$SOPS_FILE"
            echo "Created registry/${KIND}.sops.yaml"
        done
        unset L2_CREDS L2_USER L2_PASS
    fi

    echo "Bootstrap complete. Run 'ore secrets push' to upload the vault to ZOT."

# Pull the latest src-vault OCI artifact from ZOT and restore to local vault dir.
secrets-sync:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
    mkdir -p "$VAULT_DIR/logs"

    [[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — first sync needs admin to push the vault"; exit 1; }
    CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
    ZOT_USER=$(echo "$CREDS" | grep zot_username | awk '{print $2}')
    ZOT_PASS=$(echo "$CREDS" | grep zot_password | awk '{print $2}')

    TMPDIR_CFG=$(mktemp -d)
    AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
    printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"

    # Hard constraint (ADR-017 src-vault-cosign-signed): verify before trusting artifact.
    [[ -n "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "vault.cosign.pub_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
    [[ -f "$COSIGN_PUB_PATH" ]] || { rm -rf "$TMPDIR_CFG"; echo "Cosign pubkey not found at $COSIGN_PUB_PATH"; exit 1; }
    echo "Verifying src-vault/$VAULT_ID:latest signature (tlog=$COSIGN_TLOG)..."
    cosign verify $COSIGN_VERIFY_FLAGS --key "$COSIGN_PUB_PATH" "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
        || { rm -rf "$TMPDIR_CFG"; echo "Vault artifact signature FAILED — rejecting pull"; exit 1; }

    DOCKER_CONFIG="$TMPDIR_CFG" oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$VAULT_DIR"

    rm -rf "$TMPDIR_CFG"
    unset ZOT_USER ZOT_PASS CREDS AUTH_B64
    echo "Vault synced to $VAULT_DIR"

    LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"pull","detail":"secrets-sync"}' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}")
    echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"

# Push the local vault snapshot to ZOT as an OCI artifact (cosign-signed).
secrets-push:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }
    [[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml — run secrets-bootstrap first"; exit 1; }

    # Hard constraint (ADR-017 src-vault-cosign-signed): sign required.
    [[ -n "$COSIGN_KEY_PATH" ]] || { echo "vault.cosign.key_path not set in ~/.config/ontoref/config.ncl"; exit 1; }
    [[ -f "$COSIGN_KEY_PATH" ]] || { echo "Cosign key not found at $COSIGN_KEY_PATH"; exit 1; }

    CREDS=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS")
    ZOT_USER=$(echo "$CREDS" | grep '^zot_username:' | awk '{print $2}')
    ZOT_PASS=$(echo "$CREDS" | grep '^zot_password:' | awk '{print $2}')
    VAULT_KEY_VAL=$(echo "$CREDS" | grep '^vault_key:' | awk '{print $2}')
    # cosign_password optional for back-compat with vaults bootstrapped before
    # the field existed; fallback to existing $COSIGN_PASSWORD env if unset.
    COSIGN_PWD_VAL=$(echo "$CREDS" | grep '^cosign_password:' | awk '{print $2}')

    echo "Creating vault snapshot..."
    case "$VAULT_TOOL" in
        restic) RESTIC_PASSWORD="$VAULT_KEY_VAL" restic -r "$VAULT_DIR/repo" backup "$VAULT_DIR" --tag "secrets-push" ;;
        kopia)  KOPIA_PASSWORD="$VAULT_KEY_VAL" kopia --config-file "$VAULT_DIR/repo/kopia.config" snapshot create "$VAULT_DIR" ;;
    esac
    unset VAULT_KEY_VAL

    TMPDIR_CFG=$(mktemp -d)
    AUTH_B64=$(printf '%s:%s' "$ZOT_USER" "$ZOT_PASS" | base64)
    printf '{"auths":{"%s":{"auth":"%s"}}}' "${REGISTRY}" "$AUTH_B64" > "$TMPDIR_CFG/config.json"
    unset ZOT_USER ZOT_PASS CREDS AUTH_B64

    [[ -f "$VAULT_DIR/logs/access.jsonl" ]] || {
        echo "access.jsonl missing — re-run 'ore secrets bootstrap' to initialize"
        rm -rf "$TMPDIR_CFG"
        exit 1
    }

    echo "Pushing src-vault/${VAULT_ID}:latest to $REGISTRY..."
    # oras requires relative paths for layer files — cd into the vault dir.
    # Walk src-vault/ to add scope files (.ncl), encrypted credentials (.sops.yaml),
    # and recipient public keys (.age.pub) as typed layers.
    LAYERS=("access.sops.yaml:application/vnd.ontoref.vault.access.v1"
            "logs/access.jsonl:application/vnd.ontoref.vault.audit.v1")
    if [[ -d "$VAULT_DIR/src-vault" ]]; then
        while IFS= read -r f; do
            REL="${f#${VAULT_DIR}/}"
            case "$REL" in
                src-vault/scopes/*.ncl)         LAYERS+=("$REL:application/vnd.ontoref.vault.scope.v1") ;;
                src-vault/registry/*.sops.yaml) LAYERS+=("$REL:application/vnd.ontoref.vault.credential.v1") ;;
            esac
        done < <(find "$VAULT_DIR/src-vault" -type f \( -name "*.ncl" -o -name "*.sops.yaml" \))
    fi

    PUSH_LOG=$(mktemp)
    set +e
    ( cd "$VAULT_DIR" && DOCKER_CONFIG="$TMPDIR_CFG" oras push "${REGISTRY}/src-vault/${VAULT_ID}:latest" \
        --artifact-type "application/vnd.ontoref.vault.v1" \
        "${LAYERS[@]}" ) > "$PUSH_LOG" 2>&1
    PUSH_EXIT=$?
    set -e
    cat "$PUSH_LOG"
    if [[ $PUSH_EXIT -ne 0 ]]; then
        rm -rf "$TMPDIR_CFG" "$PUSH_LOG"
        echo "oras push failed (exit $PUSH_EXIT)"
        exit 1
    fi

    VAULT_DIGEST=$(grep -oE 'sha256:[a-f0-9]{64}' "$PUSH_LOG" | head -1)
    rm -f "$PUSH_LOG"
    if [[ -z "$VAULT_DIGEST" ]]; then
        rm -rf "$TMPDIR_CFG"
        echo "Could not extract digest from oras push output — cannot sign"
        exit 1
    fi

    IMAGE_REF="${REGISTRY}/src-vault/${VAULT_ID}@${VAULT_DIGEST}"
    echo "Signing $IMAGE_REF (tlog=$COSIGN_TLOG)..."
    # cosign needs DOCKER_CONFIG too — it HEADs the manifest before signing.
    # COSIGN_PASSWORD: from access.sops.yaml when present, else fall back to env
    # (back-compat with vaults bootstrapped before the field existed).
    if [[ -n "$COSIGN_PWD_VAL" ]]; then
        DOCKER_CONFIG="$TMPDIR_CFG" COSIGN_PASSWORD="$COSIGN_PWD_VAL" \
            cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
    else
        DOCKER_CONFIG="$TMPDIR_CFG" \
            cosign sign $COSIGN_SIGN_FLAGS --key "$COSIGN_KEY_PATH" --yes "$IMAGE_REF"
    fi
    unset COSIGN_PWD_VAL
    echo "Vault artifact signed"

    rm -rf "$TMPDIR_CFG"

    LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"push","detail":"secrets-push digest=%s"}' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-unknown}" "$VAULT_DIGEST")
    echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
    echo "Vault pushed and signed: $REGISTRY/src-vault/$VAULT_ID:latest"

# Open the vault for editing. Acquires an OCI lock at src-vault/<id>:lock with
# TTL (default 60 min). Conflicts: if another lock is active and not expired,
# error with the holder's identity. After editing, run 'ore secrets close'.
secrets-open:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — run secrets-sync first"; exit 1; }
    [[ -n "$REGISTRY" ]]    || { echo "sops.registry_endpoint not declared in project.ncl"; exit 1; }

    vault_zot_config_open || exit 1
    trap 'rm -rf "$TMPDIR_CFG"' EXIT

    NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    NOW_EPOCH=$(date -u +%s)
    EXPIRES_EPOCH=$(( NOW_EPOCH + VAULT_LOCK_TTL_MIN * 60 ))
    EXPIRES_TS=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
        || date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
        || echo "")
    HOLDER="${USER:-$(whoami)}"
    ACTOR="${ONTOREF_ACTOR:-developer}"

    EXISTING=$(vault_lock_fetch)
    if [[ -n "$EXISTING" ]]; then
        EXIST_BY=$(echo "$EXISTING"     | jq -r .locked_by)
        EXIST_ACTOR=$(echo "$EXISTING"  | jq -r .actor)
        EXIST_SINCE=$(echo "$EXISTING"  | jq -r .since)
        EXIST_EXP=$(echo "$EXISTING"    | jq -r .expires)
        # Compare expires_epoch with now to detect abandoned lock.
        EXIST_EXP_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXIST_EXP" +%s 2>/dev/null \
            || date -u -d "$EXIST_EXP" +%s 2>/dev/null || echo 0)
        if [[ "$EXIST_BY" == "$HOLDER" ]]; then
            echo "Vault already locked by you ($HOLDER, since $EXIST_SINCE). Refreshing TTL."
        elif [[ "$EXIST_EXP_EPOCH" -gt "$NOW_EPOCH" ]]; then
            echo "Vault locked by $EXIST_BY (actor=$EXIST_ACTOR) since $EXIST_SINCE, expires $EXIST_EXP."
            echo "If lock is abandoned and you have admin rights, run: ore secrets force-unlock"
            exit 1
        else
            echo "Lock expired ($EXIST_EXP < $NOW_TS). Force-closing abandoned lock from $EXIST_BY."
        fi
    fi

    vault_lock_push "$HOLDER" "$ACTOR" "$NOW_TS" "$EXPIRES_TS" \
        || { echo "Failed to push lock artifact (registry ACL may not allow)"; exit 1; }
    echo "Lock acquired: $HOLDER until $EXPIRES_TS"

    echo "Opening vault $VAULT_ID for editing..."
    SOPS_AGE_KEY_FILE="$MASTER_KEY" sops "$ACCESS_SOPS"
    echo "Edit complete. Run 'ore secrets close' to push and release the lock."

# Close the vault: impact report → push updated state → release lock.
# ADR-017 G2: list which credential files changed and which RegistryEntries
# they affect. Operator confirms before the push proceeds.
secrets-close:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"

    # Pre-flight impact analysis.
    CHANGED=$(vault_impact_changed_creds)
    if [[ -n "$CHANGED" ]]; then
        echo "──────────────────────────────────────────────────────────────────"
        echo "  Credential changes detected since last snapshot:"
        echo "$CHANGED" | sed 's/^/    /'
        echo
        IMPACT=$(echo "$CHANGED" | vault_impact_registries)
        if [[ -n "$IMPACT" ]]; then
            echo "  Affected RegistryEntry references:"
            echo "$IMPACT" | awk -F'\t' '{ printf "    %s  via %s (%s)\n", $1, $2, $3 }'
            echo
            echo "  Domains/modes declaring uses_registry to any of the entries above"
            echo "  will resolve fresh credentials on next pull. Audit consumers if any"
            echo "  service uses cached credentials in ~/.cache/ontoref/."
        else
            echo "  No RegistryEntry in this project's manifest references the changed file(s)."
            echo "  Consumers in OTHER projects may still depend on these credentials."
        fi
        echo "──────────────────────────────────────────────────────────────────"
        if [[ -z "${ONTOREF_SECRETS_YES:-}" ]]; then
            read -r -p "  Proceed with push? [y/N] " ans
            [[ "$ans" =~ ^[Yy] ]] || { echo "Aborted. Lock remains held."; exit 1; }
        fi
    else
        echo "No credential changes since last snapshot."
    fi

    just secrets-push
    vault_zot_config_open || { echo "[close] cannot resolve ZOT config — lock NOT released"; exit 1; }
    trap 'rm -rf "$TMPDIR_CFG"' EXIT
    EXISTING=$(vault_lock_fetch)
    if [[ -z "$EXISTING" ]]; then
        echo "No lock to release."
        exit 0
    fi
    EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
    HOLDER="${USER:-$(whoami)}"
    if [[ "$EXIST_BY" != "$HOLDER" ]]; then
        echo "Lock held by $EXIST_BY, not $HOLDER. Refusing to release. Use force-unlock if needed."
        exit 1
    fi
    vault_lock_release
    echo "Lock released."

# Force-release an abandoned or stuck lock (admin only).
# Lock metadata records who force-closed for auditability.
secrets-force-unlock:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }
    vault_zot_config_open || exit 1
    trap 'rm -rf "$TMPDIR_CFG"' EXIT
    EXISTING=$(vault_lock_fetch)
    if [[ -z "$EXISTING" ]]; then
        echo "No lock to release."
        exit 0
    fi
    EXIST_BY=$(echo "$EXISTING" | jq -r .locked_by)
    EXIST_SINCE=$(echo "$EXISTING" | jq -r .since)
    HOLDER="${USER:-$(whoami)}"
    NOW_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    echo "Force-unlocking vault $VAULT_ID."
    echo "  was held by:    $EXIST_BY since $EXIST_SINCE"
    echo "  force-closed by: $HOLDER at $NOW_TS"
    # Emit a tombstone-tagged audit before delete so the action is traceable.
    LOG_ENTRY=$(printf '{"ts":"%s","actor":"%s","op":"force-unlock","detail":"was-held-by=%s since=%s"}' \
        "$NOW_TS" "${ONTOREF_ACTOR:-developer}" "$EXIST_BY" "$EXIST_SINCE")
    echo "$LOG_ENTRY" >> "$VAULT_DIR/logs/access.jsonl"
    vault_lock_release
    echo "Lock released."

# Add a recipient. Two modes:
# - DECLARATIVE (project declares recipient_rules): error with guidance to edit
#   project.ncl::sops.recipient_groups and run 'secrets rekey' — that is the
#   canonical source of truth. Mutating sops files directly would diverge from
#   the declared state and the next rekey would re-clobber the change.
# - LEGACY (no rules declared): apply --add-age across every *.sops.yaml in the vault.
secrets-add-key AGE_PUBKEY ROLE:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
    if [[ "$HAS_RULES" == "true" ]]; then
        echo "This vault uses declarative recipient_rules — direct add-key would drift."
        echo "Edit project.ncl: add the pubkey to sops.recipient_groups[\"{{ROLE}}\"]"
        echo "Then: ore secrets rekey"
        exit 1
    fi
    echo "Adding recipient (role: {{ROLE}}) to vault $VAULT_ID..."
    while IFS= read -r f; do
        echo "  + ${f#${VAULT_DIR}/}"
        SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --add-age "{{AGE_PUBKEY}}" "$f" >/dev/null
    done < <(find "$VAULT_DIR" -name "*.sops.yaml")
    echo "Recipient added to $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
    echo "Run 'ore secrets push' to distribute the updated vault."

# Remove a recipient. Same DECLARATIVE/LEGACY split as add-key.
secrets-remove-key AGE_PUBKEY:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '(.sops.recipient_rules // []) | length > 0')
    if [[ "$HAS_RULES" == "true" ]]; then
        echo "This vault uses declarative recipient_rules — direct remove-key would drift."
        echo "Edit project.ncl: remove the pubkey from sops.recipient_groups[*]"
        echo "Then: ore secrets rekey"
        exit 1
    fi
    echo "Removing recipient from vault $VAULT_ID..."
    while IFS= read -r f; do
        echo "  - ${f#${VAULT_DIR}/}"
        SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys --remove-age "{{AGE_PUBKEY}}" "$f" >/dev/null
    done < <(find "$VAULT_DIR" -name "*.sops.yaml")
    echo "Recipient removed from $(find "$VAULT_DIR" -name "*.sops.yaml" | wc -l | tr -d ' ') file(s)."
    echo "Rotate registry tokens if the removed key was suspected of compromise."

# Re-encrypt all sops files. When project declares recipient_rules, regenerate
# <vault_dir>/.sops.yaml from project.ncl FIRST so updatekeys reads the fresh
# recipient unions per path. Otherwise, updatekeys uses whatever .sops.yaml
# already exists (legacy mode).
secrets-rekey:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    RULES_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
    GROUPS_JSON=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
    HAS_RULES=$(echo "$RULES_JSON" | jq 'length > 0')

    if [[ "$HAS_RULES" == "true" ]]; then
        SOPS_CFG_PATH="$VAULT_DIR/.sops.yaml"
        echo "creation_rules:" > "$SOPS_CFG_PATH"
        # Use YAML single-quoted strings for path_regex so backslash escapes
        # like \\. pass through to sops without YAML interpreting them.
        echo "$RULES_JSON" | jq -r --argjson groups "$GROUPS_JSON" '
            .[] | "  - path_regex: '"'"'\(.path)'"'"'\n    age: " +
            ([.groups[] | $groups[.] // []] | flatten | unique | join(","))
        ' >> "$SOPS_CFG_PATH"
        echo "Regenerated $SOPS_CFG_PATH from project.ncl ($(echo "$RULES_JSON" | jq length) rule(s))."
    fi

    echo "Re-keying all sops files in $VAULT_DIR..."
    find "$VAULT_DIR" -name "*.sops.yaml" | while read -r f; do
        echo "  updatekeys: $f"
        SOPS_AGE_KEY_FILE="$MASTER_KEY" sops updatekeys "$f"
    done
    echo "Re-key complete. Run 'ore secrets push' to distribute."

# Add or update the cosign_password field inside access.sops.yaml without
# opening the editor. Targets vaults bootstrapped before the field existed.
# Idempotent: if the field is already present, value is replaced.
# Recover a corrupted/missing access.sops.yaml by pulling the src-vault OCI
# artifact from ZOT. Verified disaster-recovery flow (qa.ncl::credential-vault-
# disaster-recovery). Requires the project.ncl::sops.registry_endpoint and
# valid ZOT credentials in your environment via DOCKER_CONFIG (or admin
# credentials passed inline).
secrets-recover:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -n "$REGISTRY" ]] || { echo "sops.registry_endpoint not declared"; exit 1; }

    # When access.sops.yaml is corrupt we cannot decrypt it to obtain credentials.
    # Caller must provide ZOT credentials via env (RECOVERY_ZOT_USER + _PASS) OR
    # have a pre-existing valid DOCKER_CONFIG pointing at this registry.
    if [[ -n "${RECOVERY_ZOT_USER:-}" && -n "${RECOVERY_ZOT_PASS:-}" ]]; then
        TMPCFG=$(mktemp -d)
        AUTH=$(printf '%s:%s' "$RECOVERY_ZOT_USER" "$RECOVERY_ZOT_PASS" | base64)
        printf '{"auths":{"%s":{"auth":"%s"}}}' "$REGISTRY" "$AUTH" > "$TMPCFG/config.json"
        export DOCKER_CONFIG="$TMPCFG"
        trap 'rm -rf "$TMPCFG"' EXIT
        echo "Using RECOVERY_ZOT_USER credentials."
    elif [[ -n "${DOCKER_CONFIG:-}" ]]; then
        echo "Using existing DOCKER_CONFIG=$DOCKER_CONFIG"
    else
        echo "No credentials available. Set RECOVERY_ZOT_USER + RECOVERY_ZOT_PASS"
        echo "or DOCKER_CONFIG before invoking."
        exit 1
    fi

    RECOVERY=$(mktemp -d)
    echo "Pulling src-vault/${VAULT_ID}:latest from $REGISTRY..."
    if ! oras pull "${REGISTRY}/src-vault/${VAULT_ID}:latest" --output "$RECOVERY"; then
        rm -rf "$RECOVERY"
        echo "oras pull failed — verify registry and credentials"
        exit 1
    fi

    [[ -f "$RECOVERY/access.sops.yaml" ]] || {
        rm -rf "$RECOVERY"
        echo "Recovered artifact does not contain access.sops.yaml"
        exit 1
    }

    mkdir -p "$VAULT_DIR" "$VAULT_DIR/logs" "$VAULT_DIR/src-vault"
    cp "$RECOVERY/access.sops.yaml" "$ACCESS_SOPS"
    [[ -d "$RECOVERY/logs"      ]] && cp -R "$RECOVERY/logs/."      "$VAULT_DIR/logs/"      2>/dev/null || true
    [[ -d "$RECOVERY/src-vault" ]] && cp -R "$RECOVERY/src-vault/." "$VAULT_DIR/src-vault/" 2>/dev/null || true
    rm -rf "$RECOVERY"

    # Verify decrypts cleanly with the configured master key.
    if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" >/dev/null 2>&1; then
        echo "Recovered. access.sops.yaml decrypts with $MASTER_KEY"
        printf '{"ts":"%s","actor":"%s","op":"recover","detail":"access.sops.yaml restored from %s"}\n' \
            "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ONTOREF_ACTOR:-developer}" \
            "${REGISTRY}/src-vault/${VAULT_ID}:latest" >> "$VAULT_DIR/logs/access.jsonl"
    else
        echo "Restored but cannot decrypt — your .kage may not be a recipient"
        exit 1
    fi

secrets-set-cosign-password:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -f "$ACCESS_SOPS" ]] || { echo "access.sops.yaml absent — bootstrap first"; exit 1; }

    read -r -s -p "cosign_password (for cosign.key): " COSIGN_PWD; echo
    [[ -n "$COSIGN_PWD" ]] || { echo "empty password rejected"; exit 1; }

    # `sops set` updates a single field in-place, preserving the original
    # recipient set (which lives in the file's sops metadata block). No
    # decrypt + re-encrypt cycle, no leak of plaintext to disk.
    SOPS_AGE_KEY_FILE="$MASTER_KEY" sops set "$ACCESS_SOPS" '["cosign_password"]' "\"$COSIGN_PWD\""
    unset COSIGN_PWD

    # Verify the field is present after the update.
    if SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt --extract '["cosign_password"]' "$ACCESS_SOPS" >/dev/null 2>&1; then
        echo "  cosign_password set in $ACCESS_SOPS"
        echo "  Run 'ore secrets push' to upload the updated vault."
    else
        echo "  set succeeded but field still not extractable — investigate"
        exit 1
    fi

# Audit vault constraints from ADR-017:
#   --check bootstrap-credentials   access.sops.yaml can be decrypted by master_key
#   --check no-credential-env       manifest does not declare credential_env
#   --check recipients              every .sops.yaml has ≥ 2 recipients (multi-recipient mandatory)
secrets-audit check="":
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"

    CHECK="${check:-all}"
    PASS=0

    if [[ "$CHECK" == "bootstrap-credentials" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  bootstrap-credentials: "
        if [[ ! -f "$ACCESS_SOPS" ]]; then
            echo "FAIL (access.sops.yaml not found at $ACCESS_SOPS)"
            PASS=1
        else
            DECRYPT_CHECK=$(SOPS_AGE_KEY_FILE="$MASTER_KEY" sops --decrypt "$ACCESS_SOPS" 2>&1)
            if [[ $? -eq 0 ]]; then echo "OK"; else echo "FAIL (decrypt error)"; PASS=1; fi
        fi
    fi

    if [[ "$CHECK" == "no-credential-env" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  no-credential-env: "
        if grep -r "credential_env" "${PROJECT_ROOT}/.ontology/manifest.ncl" 2>/dev/null | grep -q .; then
            echo "FAIL (credential_env found in manifest.ncl — excluded by schema)"
            PASS=1
        else
            echo "OK"
        fi
    fi

    if [[ "$CHECK" == "recipients" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  multi-recipient-mandatory: "
        if [[ -f "$ACCESS_SOPS" ]]; then
            RECIPIENT_COUNT=$(grep -c "age1" "$ACCESS_SOPS" 2>/dev/null || echo 0)
            if [[ "$RECIPIENT_COUNT" -ge 2 ]]; then echo "OK ($RECIPIENT_COUNT recipients)";
            else echo "FAIL (only $RECIPIENT_COUNT recipient — multi-recipient required)"; PASS=1; fi
        else
            echo "SKIP (no access.sops.yaml)"
        fi
    fi

    # ADR-019 — recipient routing constraints (skipped in legacy mode).
    RULES=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_rules // []')
    GROUPS=$(echo "$PROJECT_JSON" | jq -c '.sops.recipient_groups // {}')
    HAS_RULES=$(echo "$RULES" | jq 'length > 0')

    if [[ "$CHECK" == "recipient-routing-coherent" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  recipient-routing-coherent: "
        if [[ "$HAS_RULES" != "true" ]]; then
            echo "SKIP (legacy single-set mode)"
        else
            # Constraint 1: every group referenced by a rule must be declared in groups
            UNDECLARED=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
                [.[] | .groups[] | select(($g | has(.)) | not)] | unique | join(",")
            ')
            # Constraint 2: every rule must resolve to ≥ 1 recipient
            EMPTY_RULES=$(echo "$RULES" | jq -r --argjson g "$GROUPS" '
                [.[] | select((.groups | map($g[.] // []) | flatten | unique | length) == 0) | .path] | join(",")
            ')
            if [[ -n "$UNDECLARED" ]]; then
                echo "FAIL (undeclared groups referenced: $UNDECLARED)"
                PASS=1
            elif [[ -n "$EMPTY_RULES" ]]; then
                echo "FAIL (rules resolve to zero recipients: $EMPTY_RULES)"
                PASS=1
            else
                echo "OK ($(echo "$RULES" | jq length) rule(s), $(echo "$GROUPS" | jq 'keys | length') group(s))"
            fi
        fi
    fi

    if [[ "$CHECK" == "recipient-routing-coverage" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  recipient-routing-coverage: "
        if [[ "$HAS_RULES" != "true" ]]; then
            echo "SKIP (legacy single-set mode)"
        elif [[ ! -d "$VAULT_DIR" ]]; then
            echo "SKIP (vault not bootstrapped)"
        else
            # For each *.sops.yaml in the vault, verify at least one rule.path matches.
            UNCOVERED=""
            while IFS= read -r f; do
                rel="${f#${VAULT_DIR}/}"
                # Try each rule's regex; rule order doesn't matter for coverage.
                MATCHED=$(echo "$RULES" | jq -r --arg path "$rel" '
                    [.[] | select($path | test(.path))] | length
                ')
                [[ "$MATCHED" == "0" ]] && UNCOVERED="$UNCOVERED $rel"
            done < <(find "$VAULT_DIR" -name "*.sops.yaml" 2>/dev/null)
            if [[ -n "$UNCOVERED" ]]; then
                echo "FAIL (paths without matching rule:$UNCOVERED)"
                PASS=1
            else
                echo "OK"
            fi
        fi
    fi

    if [[ "$CHECK" == "no-multi-vault" ]] || [[ "$CHECK" == "all" ]]; then
        echo -n "  no-multi-vault: "
        if grep -q "sops\.vaults *=" "$PROJECT_NCL" 2>/dev/null; then
            echo "FAIL (sops.vaults declaration found — multi-vault is unimplemented per ADR-019)"
            PASS=1
        else
            echo "OK"
        fi
    fi

    exit $PASS

# Read existing access.sops.yaml recipients and emit a recipient_groups +
# recipient_rules stanza ready to paste into project.ncl::sops. Read-only —
# does not mutate project.ncl. The default policy maps every existing recipient
# to BOTH `admin` and `developer` groups (preserves current behaviour exactly)
# and emits two rules: one for access.sops.yaml (admin+developer), one for
# everything else (developer-only). Operator edits the stanza before pasting
# to refine which keys belong to which group.
secrets-migrate-to-declarative:
    #!/usr/bin/env bash
    source "{{source_directory()}}/_secrets_lib.sh"
    [[ -f "$ACCESS_SOPS" ]] || { echo "No access.sops.yaml at $VAULT_DIR — bootstrap or sync first"; exit 1; }

    # Already declarative? Refuse to overwrite — operator must rekey from project.ncl.
    HAS_RULES=$(echo "$PROJECT_JSON" | jq -r '.sops.recipient_rules // [] | length > 0')
    if [[ "$HAS_RULES" == "true" ]]; then
        echo "[migrate] project.ncl already declares recipient_rules — nothing to migrate."
        echo "  Run 'just secrets-rekey' if you changed recipient_groups and need to re-encrypt."
        exit 0
    fi

    # Extract recipients from the existing access.sops.yaml metadata. sops
    # records them under sops.age[].recipient as age1... entries — file format
    # may be YAML or JSON depending on input-type, so use a generic regex.
    RECIPIENTS=$(grep -oE 'age1[a-z0-9]+' "$ACCESS_SOPS" | sort -u)
    [[ -n "$RECIPIENTS" ]] || { echo "[migrate] no age recipients found in $ACCESS_SOPS"; exit 1; }
    COUNT=$(echo "$RECIPIENTS" | wc -l | tr -d ' ')

    echo "# ── recipient routing migration helper (read-only) ────────────────────"
    echo "# Detected $COUNT recipient(s) in $ACCESS_SOPS:"
    echo "$RECIPIENTS" | sed 's/^/#   - /'
    echo "#"
    echo "# Default policy: all recipients in both 'admin' and 'developer' groups."
    echo "# This preserves current behaviour exactly (no key loses access). Edit"
    echo "# the groups below to scope down before pasting into project.ncl::sops."
    echo "#"
    echo "# Paste THIS into your project.ncl::sops record (replacing any existing"
    echo "# recipient_groups / recipient_rules / SOPS_AGE_RECIPIENTS env reliance):"
    echo "# ──────────────────────────────────────────────────────────────────────"
    echo
    AGE_LIST=$(echo "$RECIPIENTS" | awk '{printf "      \"%s\",\n", $0}')
    printf '  recipient_groups = {\n'
    printf '    admin = [\n%s\n    ],\n' "$AGE_LIST"
    printf '    developer = [\n%s\n    ],\n' "$AGE_LIST"
    printf '  },\n'
    printf '  recipient_rules = [\n'
    printf '    { path = "access\\\\.sops\\\\.yaml$",          groups = ["admin", "developer"] },\n'
    printf '    { path = "registry/.*\\\\.sops\\\\.yaml$",     groups = ["developer"] },\n'
    printf '    { path = "scopes/.*\\\\.ncl$",                 groups = ["admin"] },\n'
    printf '  ],\n'
    echo
    echo "# ──────────────────────────────────────────────────────────────────────"
    echo "# Next steps:"
    echo "#   1. Edit project.ncl::sops to insert the stanza above."
    echo "#   2. Refine groups (move keys between admin/developer to scope access)."
    echo "#   3. Refine recipient_rules paths to match your repo layout."
    echo "#   4. Run: just secrets-rekey   (re-encrypts every *.sops.yaml using"
    echo "#      the new declared rules — same recipients = idempotent no-op)."
    echo "#   5. Verify: just secrets-audit   (runs ADR-019 coherency checks)."
