Commit Graph
13 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 2e7fb84454 fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed
keys, but env_get() compared against the bare name — verified against a real written
.env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5
call sites uses the bare name, so this always came back empty. Two consequences: the
"recover what earlier runs already decided" block never recovers anything — its own
comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose
.env had been written twenty minutes earlier", and that exact bug was still live — and
re-running the Environment step showed the wrong default (9000, tailnet URL) instead
of the machine's actual current config. Fixed to check the prefixed key first,
falling back to the bare one for an old unprefixed file, matching the runtime's own
`OFFICERDEV_X ?? X` priority.

pg_password_from_env_file() returned success with an empty string when the compose
.env existed but had no POSTGRES_PASSWORD= line. The caller
(`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would
then treat that as "reuse this password" and build POSTGRES_URL with a blank
credential rather than falling through to provision fresh. Only reachable via a
hand-edited or corrupted file — write_pg_compose always writes the line — but
"silently reuse a blank password" was the wrong direction to fail in. Now returns 1
when the line is missing or empty.

SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to
"services", but never actually persisted across the machine-setup -> officer-setup
boundary — not one of the three keys load_machine_answers recognised. A customised
bridge name from machine-setup would silently not carry over, and Proxy would land
back on "services" regardless of what Docker was actually told to use. Now saved by
machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(),
and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level
assignment runs at source time, before the answers file could have populated the
variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to
solve.

All three verified with isolated reproductions before and after: env_get against a
real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal
and a corrupted compose .env, and the full save/load/re-derive chain for
SETUP_DOCKER_NETWORK. bash -n clean on every touched file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:01:52 +01:00
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.

The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.

Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.

Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:

  - grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
    table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
    jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
    dependency elsewhere in this script, so that's a correctness fix, not just a port.
  - ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
    enough for NPM's container to reach) — both silently mis-reported on macOS, since the
    "command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
    New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
  - getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
    its full 30-minute timeout regardless of whether the record actually resolved. macOS
    branch uses dscacheutil.
  - stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
    option -- c"), and the failure was VISIBLE and made every layout directory report as
    wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
  - apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
    installers (one literally named install_update_linux.sh, the other hardcoding
    "_Linux_" into the release asset URL) — each gets a brew branch, formula names
    verified against the live Homebrew catalog rather than assumed.
  - sudo -u "$USERNAME" to become the SAME account the process already is — real on
    Linux, where this whole script runs as root and dropping to the owner needs no
    password; on macOS the process never escalates in the first place, so re-invoking
    sudo for yourself asks for a real password on every call, or fails outright without
    sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
    opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
    setup's own as_owner() (already the shared entry point there) got the same branch,
    and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
    were rerouted through it too.

Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).

Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.

Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
2026-08-26 21:10:08 +01:00
pastilhasandClaude Opus 5 b9524ee4d5 prefix the env with OFFICERDEV_, and keep reading the bare names
PORT, PUBLIC_URL and POSTGRES_URL are names half the software in the world
uses. Bun auto-loads .env into EVERY process started in the platform directory,
so a bare PORT reaches the sidecars, the tools they spawn and anything an agent
runs from here — and a bare POSTGRES_URL reaches things with no business
holding a database credential.

Four parts.

officer-setup now writes OFFICERDEV_-prefixed names, with the reasoning in the
generated file so whoever opens it later knows why.

Every consumer reads `OFFICERDEV_X ?? X` — eleven sites across the databases,
the DAV router, officer-url.mjs, os-user-postgres and two scripts. The bare
name is the FALLBACK, never the source, which is what makes this safe to ship
before every machine has been converted.

`bun run fix:env` converts a file in place. Idempotent, preserves comments and
`export`, leaves the value untouched including an `=` inside it, and refuses to
touch names belonging to other software — prefixing PATH or NODE_ENV does not
rename a variable, it deletes it.

And it runs on every restart from the server settings screen. There rather than
at boot because a restart is the one moment the file can be rewritten and take
effect in the same breath: the process about to start is the one that reads it.
Non-fatal, since the fallback means the worst case is that nothing changed.

gen-index reads either spelling out of the file directly, because it is run by
hand as often as by the setup and a bare shell has no .env loaded.

Verified by converting this machine's own .env: PORT, PUBLIC_URL and the
database all still resolve, gen:index still finds its URL, 1152 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:46:00 +00:00
pastilhasandClaude Opus 5 60ab531294 use the short tailnet name, not the FQDN
http://officer-dev:9000 rather than http://officer-dev.ts.pastilhas.dev:9000.
All three forms resolve inside the tailnet — short name, FQDN, raw 100.x — and
the short one is what anybody actually types. PUBLIC_URL is read by people too:
gen:index bakes it into the page's OpenGraph tags.

It depends on the tailnet's search domain, which every Tailscale client sets when
MagicDNS is on. A device that has lost it resolves the FQDN instead, and the
answer there is to type the longer one rather than to default everybody to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:05:04 +00:00
pastilhasandClaude Opus 5 f33e7474b0 default PUBLIC_URL to the tailnet address, not localhost
localhost is wrong on a machine with a tailnet, and quietly so: it works from the
machine itself and nowhere else, so the mistake surfaces on the first phone
rather than during setup. And PUBLIC_URL is not decoration — gen:index bakes it
into the page's OpenGraph tags, the task API hands it to scripts as
OFFICER_API_HOST, and the CalDAV profile builder refuses without it.

The tailnet is where Officer is actually reached, and it is the perimeter the
whole security model rests on now that origin checking is gone. Its address is
the honest default.

Prefers the MagicDNS name over the raw 100.x address — both work, but the name
survives a node being re-registered and is something a person can type. Falls
back to localhost with no tailnet, which is right rather than merely tolerable:
a machine with no private network has no better address to guess.

Suggests http://officer-dev.ts.pastilhas.dev:9000 on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:03:45 +00:00
pastilhasandClaude Opus 5 9d6c196848 PUBLIC_URL comes back, and gen:index takes it as an argument
Removing PUBLIC_URL earlier today was wrong, and the Build section is where it
would have surfaced: gen-index.ts exits 1 without it, so `bun gen:index` fails,
index.gen.html is never written, and `bun start` has no page to serve.

It came out because origin validation was being discontinued — but that was one
of four consumers and the only one that is gone. gen-index needs it for
OpenGraph tags, which crawlers fetch standalone and cannot resolve relative;
task-api-env builds OFFICER_API_HOST from it; and dav/router hard-requires it,
https only, to build an iOS profile.

It is also the one value this machine genuinely cannot derive, which is what
separates it from DATA_PATH and the rest that left today.

gen:index now takes a URL as its first argument, ahead of the environment and
.env: `bun gen:index https://officer.example.com`. Changing the public address is
one command rather than an edit plus a regenerate, and a second address can be
generated for without touching the install's .env.

It also validates now. A relative or scheme-less value substituted silently and
produced OpenGraph tags nothing can resolve — invisible until someone shares a
link and the preview comes back blank.

.env is PORT, PUBLIC_URL, POSTGRES_URL. Verified by running the section; all
three paths through gen:index exercised (absent, valid argument, invalid).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:07:33 +00:00
pastilhasandClaude Opus 5 8207824a81 build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in
$OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the
service user, created on first use.

The design doc planned to move ONE at-rest key into the store. What shipped
splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and
service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all
seven, so one leak opened all of them — and it was named after whichever plugin
needed it first, which is why it read as safe to change if you did not run a
vault. A core install bootstraps two, jwt and headscale; the rest appear when
their plugin first asks.

The file IS the secret. No second key unlocks it, because a key beside the store
it opens buys nothing. The gain was never secrecy, it is blast radius: bun
auto-loads .env into all twenty pm2 processes, so a key there is readable from
/proc/<pid>/environ of twenty processes — officer-music held the key that
decrypts wallet seed envelopes.

Two defects found by testing the store rather than reading it, both of which
would have shipped:

  The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than
  inheriting the database's mode, and a freshly written key lives in the WAL
  before checkpoint — so the 0600 on the database was decorative. The 0700
  directory covered it, but only until someone loosened the directory.

  PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set
  AFTER it. With twelve concurrent openers, six died on that line with
  SQLITE_BUSY. Every sidecar opens this store at boot, so they open it
  simultaneously by definition: most of them would have failed to start on a cold
  boot and none on a warm one. Fixed by ordering the pragmas; re-tested with
  twelve racing processes, one key, one row.

crypto.ts takes a purpose as its first argument now, which the design doc had
explicitly promised would not happen — 32 call sites across seven query modules.
That promise is corrected in the doc rather than quietly dropped.

Also live, not just comments: wallet/upstream.ts gated wallet storage on
process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It
asks the store now, and the question it answers changed — not "did somebody set a
variable" but "can this process open the store", since the key is created on
demand.

assertSecretsClosed covers the store, its directory and its WAL. The jwt key
mints owner tokens, so a member's shell reading it is strictly worse than the
.env leak that check was written for.

Not typechecked: node_modules is empty and installs are frozen, so the
officerdb/secret-store subpath could not be resolved at runtime here — verified
that officerdb/types fails identically, so it is the empty tree and not the new
export. The store module itself was tested directly: creation, idempotence across
processes, hasKey not creating, permissions, and the twelve-way race. Every
changed file parses; the setup section runs and degrades correctly when the
import is unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:00:12 +00:00
pastilhasandClaude Opus 5 9864fb1a49 switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.

Three things had to move together, and the middle one would have failed the boot
on its own:

  server.tsx    the listener, commented out with the variable name recorded
  hono.ts       the /api/browser mount, closed
  registry.ts   the 'browser' capability's claim on /browser, dropped

assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.

The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.

The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.

Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.

.env is down to PORT and POSTGRES_URL.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:32:29 +00:00
pastilhasandClaude Opus 5 571d0a62ff delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports
still land in DATA_PATH/bug-reports — the disk write always happened first and
the webhook was only a ping about it, so nothing about the report is lost.

It was a personal notification channel living in deployment config, on a platform
whose owner is the only person who files reports. It was also never in
.env.example: the setup script wrote a variable nothing documented, which is the
same drift as PORT, in the other direction.

Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own
channel — and is untouched.

Then a parity sweep of setup / .env.example / what the code reads, which turned
up two leftovers from earlier today:

HOME_DIR was still read in six files, each with its own `?? homedir()` fallback.
Dead since nothing sets it, but a dead read is worse than none — it reads as a
supported override. They take homedir() directly now. user-instance.ts gets a
comment on why its line stays where it is: it sits above `process.env.HOME =
homeDir`, and homedir() reads $HOME, so a read moved below that assignment would
return whichever member was last spawned into. Two of the six had fallback chains
ending in process.cwd() and '' — the second would have silently disabled whatever
consumed it rather than failing.

VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes,
though it is a plugin variable setup has never written. Commented out with the
other plugin entries.

The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and
POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which
are required by code and deliberately unwritten until the secret store lands.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes three variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:23:32 +00:00
pastilhasandClaude Opus 5 f063fc0c08 remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."

Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.

Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.

What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.

So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.

origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.

Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes four variables now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:25 +00:00
pastilhasandClaude Opus 5 3f071c0b24 the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:18:48 +00:00
pastilhasandClaude Opus 5 86079adb9a mail transport is configured in the app, not in .env
MAIL_TRANSPORT was a fallback left from the old registration flow that sent
confirmation mail. That flow is gone; the variable outlived it.

It was never the primary source anyway. getTransport reads server_config
('server-settings' → smtp) first, which already backs a full UI at Settings →
Server → SMTP and its API in api/server-settings/smtp.ts, supporting resend,
smtp and mailhog. The env var only answered when that was absent — which is a
second source of truth for something the owner can already set, with the failure
mode that a stale URL in .env silently answers for a server whose settings row
is simply empty.

Removed from transport.ts, .env.example and the setup script's Environment
section, which no longer asks for it. setup-old/ still mentions it; that is the
archive and is left alone.

Also split the try. It wrapped the read AND the transport construction and
swallowed both, so three different problems produced one message. Unreachable
database, nothing configured, and stored settings that do not build a transport
now say different things, because the fix for each is different and this message
is all the caller ever sees.

The two consumers — queue/engine.ts and auth/forgot-password.ts — now raise
until SMTP is set in the UI, which is the honest answer rather than a regression.

Not typechecked: node_modules is empty here and installs are frozen. transport.ts
parses under `bun build --no-bundle`; the setup script was run and no longer
prompts for or writes the variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:01:17 +00:00
pastilhasandClaude Opus 5 cb67b22f80 officer-setup: the environment section
Writes .env, and the whole point of the section is the two values it must not
write twice.

JWT_SECRET and VAULT_STORE_KEY are read back from any existing .env and kept.
The original script reminted JWT_SECRET on every run that agreed to regenerate
.env, which logs every device out with no stated reason, and never wrote
VAULT_STORE_KEY at all — so a scripted install had no at-rest key and the vault
and wallet refused to store anything.

VAULT_STORE_KEY is the more dangerous of the two now that it is being written.
It is not Vaultwarden's despite the name: it encrypts every secret column in
Postgres, and the wallet seed envelope on top of the owner passphrase. Changing
it is unrecoverable for the seed, because the passphrase opens the inner
envelope and that is the outer one. Said in the section, in the file it writes,
and in .env.example, which described it as Vaultwarden's and understated it.

DATA_PATH and OFFICER_ITEMS_DIR are derived from $OFFICER_ROOT rather than
asked — two questions that had to agree with each other and with the app store.

ALLOW_ANY_ORIGIN is written explicitly from whether tailscale0 exists, rather
than left to the platform default. The default is ON, which CLAUDE.md says is
only defensible because the tailnet is the perimeter; with no tailnet there is
no perimeter, so it goes out as false. Added to .env.example, which omitted it.

PORT defaults to 9000, matching .env.example. The old script used 9010; nothing
depends on either, and it is a prompt.

write_env restores the prior umask. It was set to 077 so the secrets are never
briefly world-readable, but umask is not scoped to a function and would have
made every file the later sections create owner-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:41:49 +00:00