import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { secretStorePath } from 'officerdb/secret-store';
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, toShellUsername } from './data-path';

// Real Linux accounts for members, so the surfaces that execute code can run as them.
//
// Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing
// anything here — several of the choices below look arbitrary and are not.
//
// ── The one thing to know ──
//
// `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000,
// `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege
// drop written the obvious way would look like it worked while every member's process ran as the owner —
// an isolation boundary that is silently absent, which is worse than none at all because it is believed.
//
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.

const MAX_USERNAME = 32;

/**
 * The Linux account name for a platform account: **the username the owner chose**, verbatim.
 *
 * This carried an `officer_` prefix until 2026-08-11. The prefix bought two things — no collision with a
 * system account, and a greppable record of what we created — and cost the thing the owner actually wants,
 * which is that `whoami` in a member's terminal says who they are. Measured before removing it: `useradd`
 * on this host accepts everything `validateUsername` permits, including dots, hyphens, underscores and
 * uppercase.
 *
 * What replaced the prefix's safety is a stricter ADOPTION rule in `ensureOsUser`: an existing Linux account
 * is only reused when its passwd home is already the home we expect. Without that, a platform account named
 * `root` would have adopted root. See there.
 *
 * `toShellUsername` still handles the fallback when there is no username, since the email local part can
 * contain things a Linux name cannot.
 */
export function osUserNameFor(params: { username: string | null; email: string }): string {
  const chosen = params.username?.trim();
  if (chosen) return chosen.slice(0, MAX_USERNAME);
  return toShellUsername('', params.email).slice(0, MAX_USERNAME);
}

/**
 * The login shell a new account gets: zsh where it exists, bash otherwise.
 *
 * Resolved from `/etc/shells`-style existence rather than from this process's environment. See the call site.
 */
async function defaultShell(): Promise<string> {
  for (const candidate of ['/usr/bin/zsh', '/bin/zsh', '/bin/bash']) {
    if (existsSync(candidate)) return candidate;
  }
  return '/bin/sh';
}

/** The account's home as passwd records it, or null if it has none / does not exist. */
async function passwdHome(osUser: string): Promise<string | null> {
  const result = await run(['getent', 'passwd', osUser]);
  if (!result.ok) return null;
  return result.out.split(':')[5] ?? null;
}

export type RunAsOptions = {
  /** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */
  env?: Record<string, string>;
  cwd?: string;
};

/**
 * The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything.
 *
 *   sudo -n          REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails
 *                    with "initgroups failed: Operation not permitted" for an unprivileged caller even
 *                    when reuid'ing to its OWN account — setgroups(2) is root-only, full stop. So this
 *                    cannot be done without privilege, and `-n` makes a missing sudoers entry an
 *                    immediate error instead of a process blocking on a password prompt nobody will see.
 *   --reuid/--regid  the REAL ids, not merely effective — there is nothing to switch back to.
 *   --init-groups    apply the account's supplementary groups. Without it the process keeps the OWNER'S
 *                    groups, which quietly retains access we just took away.
 *   --reset-env      drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the
 *                    target's passwd entry. Both halves matter: the parent's env carries the owner's HOME
 *                    and — in a PM2 process started in the platform directory — everything Bun auto-loaded
 *                    from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as
 *                    the member's own.
 *
 * `sudo -u <user>` alone would also work and would be shorter. It is not used because its environment
 * handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file
 * states — and "which variables cross into a member's shell" is exactly the question that must not depend
 * on a config file somebody may have edited.
 */
export function runAsArgv(osUser: string, command: string[]): string[] {
  if (!osUser) throw new Error('runAsArgv: no OS user');
  if (!command.length) throw new Error('runAsArgv: empty command');
  return [
    'sudo',
    '-n',
    'setpriv',
    `--reuid=${osUser}`,
    `--regid=${osUser}`,
    '--init-groups',
    '--reset-env',
    '--',
    ...command,
  ];
}

/**
 * Run a command as another Linux account.
 *
 * Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is
 * this function, precisely because the option that looks like it would work does nothing.
 */
export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) {
  return Bun.spawn(runAsArgv(osUser, command), {
    cwd: options.cwd,
    // Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything
    // the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside
    // `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only
    // cross into a member's process because someone wrote it there.
    env: options.env,
    stdout: 'pipe',
    stderr: 'pipe',
  });
}

async function run(command: string[]): Promise<{ ok: boolean; out: string }> {
  const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
  const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
  const code = await proc.exited;
  return { ok: code === 0, out: `${out}${err}`.trim() };
}

/** uid/gid from the passwd database, or null when the account does not exist. */
export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> {
  const uid = await run(['id', '-u', osUser]);
  if (!uid.ok) return null;
  const gid = await run(['id', '-g', osUser]);
  if (!gid.ok) return null;
  return { uid: Number(uid.out), gid: Number(gid.out) };
}

/** The member's home: `DATA_PATH/<email>/home`, where `provisionUserDirs` already put it. */
export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home');

/**
 * The first directory on the way to `path` that `osUser` cannot traverse, or null if the whole chain is fine.
 *
 * Exists because of a real and thoroughly confusing failure. A member's home is under `DATA_PATH`, which on
 * a normal install is under the OWNER'S home — and `/home/<owner>` is 750 on Debian and Ubuntu. So every
 * mode bit we set on the account tree was correct, the directory existed, and the member still could not
 * reach it, because they had no `x` on an ancestor four levels up. What surfaced was
 * `ssh-keygen: Could not stat …/.ssh: Permission denied`, which points at exactly the wrong place.
 *
 * `x` without `r` is the ask: traversal, not listing. Nobody gains the ability to enumerate the owner's home.
 */
export async function firstUntraversableAncestor(osUser: string, path: string): Promise<string | null> {
  const parts = path.split('/').filter(Boolean);
  const chain = parts.map((_, i) => `/${parts.slice(0, i + 1).join('/')}`);

  // One spawn rather than one per level: this runs on every account creation, and the chain is ~6 deep.
  const proc = runAs(osUser, [
    'sh',
    '-c',
    'for p in "$@"; do [ -x "$p" ] || { printf %s "$p"; exit 0; }; done',
    'sh',
    ...chain,
  ]);
  const out = (await new Response(proc.stdout).text()).trim();
  await proc.exited;
  return out || null;
}

export type EnsureOsUserResult =
  | { ok: true; osUser: string; uid: number; gid: number; created: boolean }
  | { ok: false; error: string };

/**
 * Create the Linux account if it does not exist, then place the ownership and mode bits.
 *
 * Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are
 * re-applied every time, so a directory the platform added later is confined without needing a
 * migration.
 *
 * Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that
 * failed must not leave a half-made user behind — the caller records the error and the platform account
 * simply has no OS account yet.
 */
export async function ensureOsUser(params: { email: string; username: string | null }): Promise<EnsureOsUserResult> {
  const osUser = osUserNameFor(params);
  const home = osUserHome(params.email);

  let created = false;
  let ids = await lookupOsUser(osUser);

  // ── Adoption, and why it is this strict ──
  //
  // An account that already exists is REUSED, which is what makes this function re-runnable. While names
  // carried an `officer_` prefix that was safe by construction: nothing else creates those. Now that the
  // name is whatever the owner typed, adoption is the dangerous path — a platform account named `root`
  // would find root in passwd, and every `runAs` for that member would then be a root shell.
  //
  // The test is the account's own home. If passwd already points it at exactly the directory we are about
  // to confine, it is ours (or a previous run's). Anything else is somebody else's account that happens to
  // share a name, and the answer is to refuse rather than to touch it.
  //
  // The uid floor is belt and braces: a system account below 1000 could in principle be created with a
  // matching home, and none of them should ever be handed to a member.
  if (ids) {
    const existingHome = await passwdHome(osUser);
    if (ids.uid < 1000) {
      return { ok: false, error: `'${osUser}' is a system account on this machine. Choose another username.` };
    }
    if (existingHome !== home) {
      return {
        ok: false,
        error:
          `'${osUser}' is already a user on this machine, with its home at ${existingHome ?? 'an unknown path'}. ` +
          `Refusing to take it over — choose another username.`,
      };
    }
  }

  if (!ids) {
    // `-M` because provisionUserDirs already made the directory, and letting useradd create it would copy
    // /etc/skel in as root-owned.
    //
    // The shell is chosen from what is INSTALLED, not from `process.env.SHELL`. That was the first version and
    // it is wrong twice: this process is started by PM2, whose environment has whatever shell PM2 was launched
    // from — often `/bin/sh` and sometimes nothing — so the member's shell depended on how the server happened
    // to be started. zsh is what the platform's own setup installs and what the shell template targets.
    const create = await run([
      'sudo',
      '-n',
      'useradd',
      '--home-dir',
      home,
      '-M',
      '--shell',
      await defaultShell(),
      osUser,
    ]);
    if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
    created = true;
    ids = await lookupOsUser(osUser);
    if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` };
  }

  const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid });
  if (!confined.ok) return { ok: false, error: confined.error };

  // Checked before anything tries to USE the home, so the error names the actual problem. Every mode bit on
  // the account tree can be right while the member still cannot get there, because `DATA_PATH` normally
  // lives under the owner's home and `/home/<owner>` is 750 on Debian and Ubuntu.
  const blocked = await firstUntraversableAncestor(osUser, home);
  if (blocked) {
    return {
      ok: false,
      error:
        `${osUser} cannot traverse into ${blocked}, so it cannot reach its own home. ` +
        `Fix with:  chmod o+x ${blocked}  ` +
        `(that grants traversal only — the directory stays unlistable.)`,
    };
  }

  // A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a
  // habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them:
  // guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty
  // home is honest about being new.
  return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
}

/**
 * Place the mode bits described in docs/per-user-linux-accounts.md § "The layout".
 *
 *   DATA_PATH          711  service user   traverse only — a member cannot enumerate the members
 *   DATA_PATH/<email>  711  service user   traverse only — a member cannot list their OWN siblings
 *   …/home             700  the member     their home
 *   every sibling      700  service user   platform-written, unreachable even by name
 *
 * 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must
 * not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is
 * 700 and owned by the service user, guessing a name gains nothing either.
 *
 * `chown` on the home is done with sudo: the service user owns the directory but cannot give it away —
 * `chown` to another user is a root-only operation on Linux regardless of who owns the file.
 */
export async function confineUserTree(params: {
  email: string;
  uid: number;
  gid: number;
}): Promise<{ ok: true } | { ok: false; error: string }> {
  const accountDir = join(DATA_PATH, params.email);
  const home = join(accountDir, 'home');

  try {
    if (!existsSync(home)) await mkdir(home, { recursive: true });

    // Traversable, not listable — for everyone EXCEPT the members themselves, who are named below.
    //
    // ── Why "not listable" could not be kept ──
    //
    // 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program
    // that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution
    // cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`:
    //
    //     openat("/home/pastilhas/officerdev/")                  = 6
    //     openat("/home/pastilhas/officerdev/data/")             = -1 EACCES
    //     openat("/home/pastilhas/officerdev/data/<email>/")     = -1 EACCES
    //
    // and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds;
    // it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so
    // this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project
    // being mysteriously unbuildable from a member's shell.
    //
    // The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email
    // addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling
    // is 700 and owned by the service user. What is given up is the account list, not any account's data.
    //
    // Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box.
    await chmod(DATA_PATH, 0o711);
    await chmod(accountDir, 0o711);

    // After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is
    // `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to
    // traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not
    // silently re-break the first.
    const uidEntry = `u:${params.uid}:rx`;
    const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]);
    if (!openUp.ok) {
      return {
        ok: false,
        error:
          `could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` +
          `Without it their own tooling cannot resolve paths inside their home.`,
      };
    }

    // Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
    const entries = await readdir(accountDir, { withFileTypes: true });
    for (const entry of entries) {
      if (entry.name === 'home') continue;
      if (!entry.isDirectory()) continue;
      await chmod(join(accountDir, entry.name), 0o700);
    }
    // And any of the standard set that does not exist yet, so a directory created later starts confined
    // rather than at the process umask.
    for (const dir of USER_DIRS) {
      if (dir === 'home') continue;
      const path = join(accountDir, dir);
      if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 });
    }

    // The home goes through sudo for BOTH operations, and that is the only form that is idempotent.
    // `chmod` requires ownership, so:
    //   - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now
    //     belongs to the member. Re-running an install would report failure on a correct tree.
    //   - chown then chmod, unprivileged: fails immediately, for the same reason.
    // Both were observed. Root does not care about either ordering, so both go through sudo and the
    // function can be run any number of times.
    const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]);
    if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` };
    const close = await run(['sudo', '-n', 'chmod', '700', home]);
    if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` };

    // ── And then let the PLATFORM in, by ACL ──
    //
    // A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for
    // the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported
    // "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11.
    //
    // These are two different doors and they need different boundaries. The terminal and the agent RUN AS the
    // member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside
    // the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's
    // process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a
    // hole, it is the honest description of who is doing the work.
    //
    // Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes
    // must be editable by the member, and a file the member writes must be editable by the platform. Mode
    // bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and
    // widening "other" would open the home to every account on the box. A shared group fails the same way
    // once you notice both parties would have to be in it, which would put every member in a group that can
    // read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are
    // inherited by everything created afterwards, by either party, whatever their umask.
    const serviceUid = process.getuid?.();
    if (serviceUid !== undefined) {
      const entries = [
        `u:${serviceUid}:rwx`,
        `u:${params.uid}:rwx`,
        `d:u:${serviceUid}:rwx`,
        `d:u:${params.uid}:rwx`,
      ].flatMap((entry) => ['-m', entry]);
      // After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier.
      const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]);
      if (!acl.ok) {
        return {
          ok: false,
          error:
            `could not set access control lists on ${home}: ${acl.out}. ` +
            `The file browser cannot read a member's home without them. ` +
            `Install the acl package (apt install acl) and retry.`,
        };
      }
    }

    // ── One directory where container bind mounts can live ──
    //
    // Created for every account, not only the ones that get a daemon: it is two `install` calls, and
    // `confineUserTree` is the function that places the whole layout rather than the one that knows who is
    // a Developer. A member promoted later finds it already correct.
    //
    // The default ACLs above are inherited by everything created in the home afterwards, including
    // `default:other::---`. A rootless container's INNER uid is neither the service user nor the member —
    // postgres:18-alpine runs as uid 70, which maps through the member's subuid range to 231141 — so it is
    // `other`, and `other` has no `x`. It cannot traverse a directory it otherwise owns.
    //
    // `3bea46f` stripped defaults from `~/.local/share/docker` and concluded the problem solved. That fixed
    // NAMED VOLUMES only. A bind mount lives wherever the member put it, and there it hits the same denial by
    // a different route — reported from a real server as `mkdir: can't create directory '…/18/docker'` on a
    // directory that already existed. A named volume passes with this bug present, which is exactly how the
    // first fix looked complete.
    //
    // Three ways to fix it, and this is the third:
    //
    //   - extend the strip to wherever the bind source is → unbounded, the member chooses the path
    //   - `d:other::--x` on the whole home → traverse for every uid, forever, to fix one local case
    //   - bless ONE directory → scoped, predictable, and already where members work
    //
    // The cost is that the file browser cannot read inside it, which is the same trade already accepted for
    // Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind
    // mount from anywhere and will hit the denial there. This is the documented place that works.
    // `.local` FIRST, explicitly, with the member's ownership. `install -d` creates missing parents but
    // applies `-o`/`-g`/`-m` only to the FINAL component, so letting it invent `.local` leaves that directory
    // root:root — inside the member's own home, unwritable by them.
    //
    // This is `71589ae` for the second time. That commit found the identical thing for `~/.config` and wrote
    // "a single wrong-owner directory in a home is the kind of thing that surfaces weeks later as one tool
    // mysteriously failing". It surfaced in twenty minutes: rootless Docker died on
    // `mkdir …/.local/share: permission denied`, and the Claude installer targets `~/.local/bin`, so it was
    // blocked by the same directory. Grep before adding another `install -d`/`-D` whose parent is implicit.
    const localDir = join(home, '.local');
    const madeLocalDir = await run([
      'sudo',
      '-n',
      'install',
      '-d',
      '-o',
      String(params.uid),
      '-g',
      String(params.gid),
      '-m',
      '700',
      localDir,
    ]);
    if (!madeLocalDir.ok) return { ok: false, error: `could not create ${localDir}: ${madeLocalDir.out}` };

    const composeDir = join(home, '.local', 'dockers');
    const madeComposeDir = await run([
      'sudo',
      '-n',
      'install',
      '-d',
      '-o',
      String(params.uid),
      '-g',
      String(params.gid),
      // 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so it
      // needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` stays
      // off, so nothing can list it. Safe because the home above is 700: no other account can traverse this
      // far in the first place, and the only uids that get here are the member's own containers.
      '-m',
      '711',
      composeDir,
    ]);
    if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` };

    // `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes.
    //
    // `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, reading
    // as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than no
    // ACL, and container storage is the one place in the home that wants ordinary mode bits and nothing else.
    //
    // AFTER the recursive grant above, or what it just set is re-inherited here.
    const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]);
    if (!stripped.ok) {
      return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` };
    }

    return { ok: true };
  } catch (ex) {
    return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
  }
}

// ── The owner's home, once somebody else has a shell ──
//
// Ubuntu's default umask is 002 with user-private groups, so everything the owner creates lands 775/664.
// Alone on a machine that is harmless. The moment a member has a login it is not: their homes are NESTED
// inside the owner's (`$OFFICER_ROOT/data/<email>/home`), so the owner's home must stay traversable AND
// readable — the ancestor-read requirement that `bun` exposed — and every dotfile in it is then legible to
// every member by default.
//
// `assertSecretsClosed` already refuses to boot on the fatal ones (`.env`, the secret store). This is the
// tier below: nothing that mints a token, but `~/.pm2/logs` is every log the platform has written,
// `~/.claude/projects` names every directory the owner works in, and `~/.config` is whatever any tool put
// there. Measured on a real machine before this existed — all of it was listable by a member.
//
// Two directories are deliberately left open, and it is not an oversight:
//
//   ~/.bun     — /usr/local/bin/bun is a SYMLINK into it, so every member's `bun` resolves through the
//                owner's home. Closing it breaks the runtime for everyone. Verified: 2.6G of package cache
//                and no credential file.
//   ~/.local/bin — /usr/local/bin/gh points here the same way. `share/` and `state/` underneath ARE closed;
//                only the binary directory is reachable.
//
// Both are really the same latent bug — a system-wide tool installed into one user's home — and the honest
// fix is to install them outside it. Until then, this function has to know about them, so it says so.
const OWNER_HOME_PRIVATE = ['.claude', '.pm2', '.config', '.npm', '.opencode', '.cache', '.tmux'];
const OWNER_HOME_PRIVATE_NESTED = ['.local/share', '.local/state'];

/**
 * Close the owner's own dotfiles to the accounts this platform hands shells to.
 *
 * Called from provisioning rather than from setup, because the directories this covers are created by USE,
 * not by install — `~/.claude` does not exist until the agent runs once. A chmod at install time would find
 * half of them missing and silently do nothing, which is the failure mode that made this necessary in the
 * first place. Running it per provision means every member's arrival re-closes whatever appeared since.
 *
 * Never throws. A member whose account provisioned fine should not be rolled back because a chmod failed;
 * the outcome is reported and the caller records it.
 */
export async function hardenOwnerHome(): Promise<{ ok: boolean; closed: string[]; error?: string }> {
  // The one caller of this function that legitimately wants the argument ignored: `getOwnerHomeDir` always
  // answers the owner, which here is exactly the question being asked.
  const home = getOwnerHomeDir('');
  const closed: string[] = [];
  try {
    for (const name of [...OWNER_HOME_PRIVATE, ...OWNER_HOME_PRIVATE_NESTED]) {
      const path = join(home, name);
      if (!existsSync(path)) continue;
      const info = await stat(path);
      // Only touch what is actually open, so a re-run is silent and the list means something.
      if (!(info.mode & 0o077)) continue;
      const done = await run(['sudo', '-n', 'chmod', '700', path]);
      if (done.ok) closed.push(name);
    }
    return { ok: true, closed };
  } catch (ex) {
    return { ok: false, closed, error: ex instanceof Error ? ex.message : String(ex) };
  }
}

/**
 * Refuse to enable OS users while a secret in the project tree is readable by them.
 *
 * `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing
 * secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would
 * leave the capability model intact and entirely bypassed.
 *
 * Checked at boot rather than documented, because a prerequisite that is only written down is one that
 * gets skipped. Returns the offending paths; the caller decides whether that is fatal.
 */
export async function findReadableSecrets(projectDir: string): Promise<string[]> {
  // The store is not in projectDir — it sits at $OFFICER_ROOT/secrets/, a sibling of the repo — so it is
  // checked separately below. Its keys are strictly worse to leak than .env ever was: the 'jwt' purpose
  // mints owner tokens, and every other purpose decrypts a credential column in Postgres.
  const candidates = ['.env', '.env.local', '.env.production'];
  const bad: string[] = [];
  for (const name of candidates) {
    const path = join(projectDir, name);
    if (!existsSync(path)) continue;
    try {
      const info = await stat(path);
      // Anything readable by group or other. 0o044 covers both read bits.
      if (info.mode & 0o044) bad.push(path);
    } catch {
      // Unreadable to us is not a leak to them; nothing to report.
    }
  }

  // The secret store and its directory. The WAL is included deliberately: a freshly written key lives
  // there before checkpoint, so a 0600 database beside a world-readable WAL protects nothing.
  const storeFile = secretStorePath();
  const storeDir = dirname(storeFile);
  for (const path of [storeDir, storeFile, `${storeFile}-wal`, `${storeFile}-shm`]) {
    if (!existsSync(path)) continue;
    try {
      const info = await stat(path);
      if (info.mode & 0o044) bad.push(path);
    } catch {
      // Same reasoning as above.
    }
  }

  return bad;
}

/**
 * Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
 *
 * Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
 * silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
 * member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
 * bypass every capability check in the codebase.
 *
 * Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
 * a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
 */
export async function assertSecretsClosed(projectDir: string): Promise<void> {
  const readable = await findReadableSecrets(projectDir);
  if (!readable.length) return;

  throw new Error(
    [
      'These files are readable by other accounts on this machine:',
      '',
      ...readable.map((p) => `  • ${p}`),
      '',
      'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which',
      'bypasses every capability check. Fix with:',
      '',
      ...readable.map((p) => `  chmod 600 ${p}`),
      '',
      'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".',
    ].join('\n'),
  );
}
