11710f283a684ce7767be0be26d5392129509a41
958
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b6feca8350 |
owner can reset a member's platform password
The gap at the other end of create-user.ts: the owner could set a password once, at creation, and never again. Losing it meant a hand-written UPDATE with an argon2 hash — the same "edit Postgres by hand" hole that creating accounts used to have. POST /api/users/:id/password, owner-gated, with a button on the row. GENERATED, not typed. The failure this exists for is "I created the account and forgot to copy the password down", and an owner typing a replacement can lose it the same way on the second go. Shown once in a dialog built to be copied — a dialog and not a toast, because a toast that times out while somebody finds a pen loses the one thing they came for. The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one character drawn from each of the four required classes, the rest from the union, then Fisher-Yates shuffled so the first four positions are not always lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases the early characters. Then it runs validatePassword on its own output, so if the rules ever gain a requirement the alphabets do not cover it throws at the one call site instead of minting passwords the login form rejects. Measured: 20,000 generations, all four classes present every time. l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and typed somewhere else. Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware already refuses any token whose iat predates it. That overwrites the null create-user leaves to mean "the owner chose this, not them" — checked, nothing reads that column except the token check. The Linux account is deliberately untouched, and the dialog says so. Members have no Linux password and never had one: ensureOsUser runs useradd with no -p, so it is created locked. Their terminal goes through setpriv, which does not authenticate; their SSH is the key the owner pasted; `su - <member>` as root does not ask. And machine-setup sets PasswordAuthentication no — verified on this host — so one could not be used to log in even if it existed. Setting one would be a new way in, not a repair. The owner is excluded: they have change-password, which asks for the current one, and resetting themselves here would end the session doing it. Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e881015df5 |
members get the same aliases as the owner
The set that just went into the owner's zshrc, mirrored into shell-skel/zshrc, so a
shell on this machine and a shell in a member's account behave alike rather than
diverging by who you happen to be.
Not a copy-paste. Three differences, each because this file has rules the owner's
appended block does not:
- `n` and `vim` are NOT repeated. The Editor block above already sets them, and
only when nvim is actually installed — better than the owner's unguarded pair.
- the eza family keeps its `else` branch rather than only being guarded. A member
with no eza still gets a coloured, grouped listing instead of bare `ls`, and
every alias in the family has a real fallback: lll, lh, ltr and l were added to
that branch too rather than silently existing only when eza does.
- lazydocker is guarded like its neighbours duf and lazygit, per this file's
stated rule that nothing is required beyond zsh itself.
eza needs no separate install for members: they share the host, and machine-setup
puts it in the core package list.
KNOWN, same shape as append_once: seedShellConfig only rewrites .zshrc while it is
still byte-for-byte the template, so a member provisioned before this keeps the old
one. No members exist right now, so nothing to migrate.
Verified: zsh -n, and the fallback branch resolving all ten aliases with eza absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fb0286e1a9 |
put rootless docker back, behind the developer gate
Uncommented at all three sites: the call and import in provisionOsAccount, the ~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in the member zshrc. Not restored unconditionally, which is how it was before. It now sits behind the same Developer check as the Postgres role — the gate that prompted disabling it in the first place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now and a name saying "database" while deciding whether you get containers is the kind of comment that goes stale silently. ~/.local/dockers is created for EVERY account rather than only Developers. It is two install calls, and confineUserTree is the function that places the layout, not the one that knows who is a Developer — so a member promoted later finds it already correct. Postgres role work is untouched and still in place. Verified: transpiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d6f01862fe |
fix a stack overflow in the wallet copy button
Mine, from the clipboard sweep. format.ts exported copyToClipboard wrapping navigator.clipboard; the sweep replaced the body call with copyToClipboard(value), so the function called itself. CopyField.tsx is the caller, so every copy button in the Wallet was an infinite recursion. Removed the wrapper rather than repointing it — helpers/clipboard already does more (execCommand fallback on an insecure origin) and CopyField imports it directly now. Found by finally running tsgo, in the officerdev-test tree, which has node_modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf7e919593 |
only a Developer gets a postgres role
The gate rootless Docker was under, which I did not know about when the Postgres role replaced it. It inherits the rule along with the purpose: a member not trusted to run containers is not thereby trusted to run databases. rolePermitsDatabase() is the single place that rule is written. Admin is deliberately NOT included — administering the platform is not developing on it, and they are separate roles precisely so they can be held separately. Say so if that is wrong. provisionOsAccount now takes the role. Two call sites: create-user passes what the owner picked, provision-linux-route reads it from the row — which makes that route the way a member promoted to Developer gets the database role they did not qualify for when their account was made. The half that makes the gate real is in updateUserRoleHandler. Without it the rule would decide what a Developer gets at creation and never look again, so demoting one would leave their role, their databases and a working password in their ~/.zshenv — a permission surviving its own revocation, with the UI then saying something untrue. Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure aborts with the role unchanged and the whole thing retryable. Promotion runs after and is non-fatal, like every other provisioning step. Demotion keeps their data, same as deletion — databases are reassigned to the platform role, not dropped — and logs where it went, so it does not look deleted. KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a role that no longer exists. Harmless (the connection just fails) but untidy, and it means `cat ~/.zshenv` shows a password that no longer works. Verified: transpiles, both call sites updated. Still no tsgo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2ac58e2007 |
drop the postgres role when a member is decommissioned
It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.
That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.
Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.
Needs the full idiom, per database, and both halves matter:
ALTER DATABASE .. OWNER TO REASSIGN OWNED does not move database ownership
REASSIGN OWNED BY .. TO .. moves tables, schemas, functions
DROP OWNED BY .. removes what is left, which after a reassign is
only the GRANTS — without it DROP ROLE still
refuses, an ACL entry is a dependency too
Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.
Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).
Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9f15de3448 |
put the postgres credentials somewhere findable
The password now lands in ~/.zshenv as PGHOST/PGPORT/PGUSER/PGPASSWORD, as well as in
~/.pgpass. Asked for on the grounds that it is an easier place to remember, which is a
real requirement — a credential you cannot find is one you will ask about every time.
.zshenv rather than the .zshrc that was asked for, for two reasons, neither about
secrecy:
- zsh sources .zshrc for INTERACTIVE shells only. Verified: `zsh -c` prints an empty
PGUSER when it is set there, and the right one from .zshenv. A script, a cron entry
or an agent turn running psql would silently get nothing.
- .zshrc is a shared template and seedShellConfig only updates it while it still
matches byte-for-byte, so members keep their edits. A per-member password in it
would strand every member on the template they were created with — a silent
maintenance break rather than a tradeoff.
Both files are still written because they are not redundant: .pgpass is what libpq
reads with no shell involved, so it is the one that works for psycopg, a systemd unit
or a compiled binary. The rotate condition now covers both — either missing means we
cannot reconstruct it from the other, so we regenerate.
Also drops the `head -1 ~/.pgpass` parsing I had put in the zshrc template. It was a
hack, and the values are in the environment before that file is read now anyway.
Verified: zsh sourcing order and both shell types, transpiles. Still no tsgo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3ca7f5f331 |
close the cross-member database leak with a login event trigger
The residue the last commit documented is gone. A database one member creates is now refused to every other member at connection time, so the catalogue metadata never becomes readable in the first place. Both obvious routes are dead ends, measured rather than assumed: datacl is not inherited from the template, and CREATE DATABASE fires no event trigger because it is a global object. What works is a `login` event trigger (PG17+) installed in template1 — event triggers live in a per-database catalogue and CREATE DATABASE copies the template's catalogues, so every member-created database carries it automatically. No naming convention, no sweep, no window. The first version put the function in `public` and a member defeated it in one statement: DROP FUNCTION public.officer_owner_only() CASCADE; -- takes the trigger with it They could not drop or disable the trigger itself, but in PG15+ `public` is owned by pg_database_owner — which resolves to THEM in their own database — and a schema owner may drop objects in it they do not own. Both objects were owned by postgres and it made no difference. Caught because I tried it rather than reasoned about it. Moved into a platform-owned schema with PUBLIC revoked. Every route then refused: DROP EVENT TRIGGER, ALTER .. DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA .. OWNER TO, CREATE OR REPLACE over the top, and PGOPTIONS=-c event_triggers=off (that GUC is superuser-only). A superuser can still set it, which is the recovery path. ensureTemplateIsolation opens its own short-lived connection because a connection cannot change database and CREATE DATABASE requires no other session on the template — a pooled connection to template1 would make every member's `createdb` fail. Verified live, end to end: alice in her own, bob refused, alice refused from bob's, postgres in, owner reads and writes normally, and both members refused CONNECT on `officer`. Probe roles and databases dropped; template1 keeps the trigger, which is the intended state. Still not typechecked — node_modules is empty in this tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2273941e71 |
give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.
Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:
- a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
but CANNOT read any application table — privileges are owner-only, so
has_table_privilege('users','UPDATE') is false. The capability model was never
reachable from here.
- the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
the source, so they fall through to scram-sha-256. Verified with a wrong password.
- revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
only revoking from PUBLIC is a lock.
So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.
Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.
KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.
Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.
NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
41cdd1e63a |
stop creating the container bind-mount directory too
~/.local/dockers existed only so a rootless container's inner uid could traverse to a bind source. No daemon, no need. Commented out with its reasoning intact in confineUserTree. .local itself stays — it is not Docker's. The claude installer targets ~/.local/bin, and the comment above it records that directory being created root-owned and blocking the install. Nothing to change in deprovisionOsAccount: it never called os-user-docker.ts. Its only Docker-shaped part is capturing /etc/subuid before userdel, which already treats a missing entry as normal and is worth keeping for any rootless tooling. Verified: transpiles. No test referenced composeDir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b32f5bc2f |
don't provision rootless docker for new members
Commented out at the call site in provisionOsAccount, with the import. The code in os-user-docker.ts stays and is now unreferenced — turning it back on is uncommenting two blocks. Only affects NEW accounts. Members provisioned before this keep their daemon, and deprovisionOsAccount still tears one down, which is what those accounts need. Verified: transpiles. tsgo has still not run in this tree (node_modules is empty). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd209483e3 |
fix the clipboard over http, and audit the rest
navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.
The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.
helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.
Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.
docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
74b4c7908a |
fix the crash over http: crypto.randomUUID is secure-context only
Chat took the whole page down at the end of every turn with `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that is not localhost it is not defined at all. Officer is reached at http://officer-dev:9000, which is neither, so all eighteen call sites in the frontend were throwing. The stack shows why it was fatal rather than merely broken: it was called inside a `useState` initialiser, so the throw happened during render and unmounted the tree. The assistant message that has no id yet is created at the end of a turn, which is exactly when it fired. No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on `Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG: same 122 bits, same version and variant bits. Verified both paths produce a UUID matching the v4 pattern, including with randomUUID deleted. `crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole of the problem. Audio recording is a different matter — getUserMedia genuinely requires a secure context and cannot be polyfilled. Nine files, eighteen call sites. The vendored hls.mjs is left alone. Two of my own mistakes on the way, both caught by parsing rather than by reading: the rewrite added an import of the helper TO the helper, and inserted another one inside a multi-line import block — the same trap as the officerdb move earlier tonight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1cfc0042f |
rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly beside officer-anthropic-proxy — a different process doing a different job — so "the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to spend a paragraph insisting the two are not the same thing. It spawns `claude`; the name says so now. Only two references were functional: the generator's CORE_PROCESSES and the CORE list in catalogue.test.ts. Everything else was prose or comments. Left alone deliberately: `x-officer-agent-token`. It looks like the same string and is not — it is the agent-handoff HTTP header, naming a per-panel bearer token, unrelated to any pm2 process. Renaming it would have changed a wire protocol to tidy a label. Historical docs keep the old name. claude-sidecar-isolation.md and open-threads-after-per-user-claude.md are dated investigations that record the PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would make that history unreadable. CLAUDE.md notes the change instead, where somebody reading those will be looking. Also worth recording, from the owner: merging this with officer-anthropic-proxy into one sidecar was investigated tonight and rejected. They stay separate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
085afe7604 |
wait for Postgres instead of failing startup work once
The failure here was not a crash — it was the opposite, and that is why it would never have been noticed. server.tsx fired initQueue() and cleanupOnStartup() as bare promises with a .catch() that logged. postgres-js connects lazily, so nothing fails at import; the first query does. If Postgres is a few seconds behind — exactly what a reboot looks like, with pm2's resurrect racing Docker starting the container — both log one line during boot and do nothing else. cleanupOnStartup is the one that matters. It marks jobs interrupted by the previous shutdown and promotes the queued backlog, so failing it once leaves those jobs marked running forever: nothing retries, nothing complains again, and the only thing that would have corrected them has already run. officerdb now exports waitForDatabase(timeoutMs = 60s): polls `select 1`, logs once while waiting, resolves true or false rather than throwing. Bounded on purpose — an unbounded wait holds a process open with no way to tell starting from hung, and the caller decides what giving up means. Deliberately NOT awaited before serve(). The listener is already up by that point and holding it closed would turn a database thirty seconds late into a reverse proxy answering connection-refused instead of a page. Requests needing the database fail honestly in the meantime. The rest of the estate was already fine, which is worth recording so nobody "fixes" it again: postgres() opens no socket at construction, officer-agent's resolveOwner is an unbounded 5s retry loop written after this exact failure cost a session, and opencode, pty, headscale and the anthropic proxy touch no database at boot at all. officer-setup's Services section also waits for pg_isready before starting pm2. Not because starting early breaks anything, but because Verify would then report a failure that is really a race — and a red line that is usually noise is a red line people stop reading. Verified waitForDatabase against a dead port: logged once, returned false after the timeout, did not throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
62cbf510cb |
no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the .profile. they derived from. The repository now contains no ecosystem file at all, and .gitignore keeps it that way. officer-setup writes one at the end, describing exactly the six processes a core install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode, officer-pty and officer-headscale. No profiles, no derivation, no plugins. The four existed because a profile has to subtract from something, so the full list had to name every plugin's process whether or not anybody installed it — and a test then had to assert the two files still agreed. Generating one file removes the subtraction, the second list and the test that policed them. It is .cjs, not the .js PM2's docs use, and that is not a preference: package.json declares "type": "module", so a .js file here is ESM and `module.exports` throws. PM2 require()s the config. Sections 10 (Services) and 11 (Verify) are built on top of it — write, startOrRestart, save, optional boot hook, then check every process is online with a sane restart count AND that the API actually answers on PORT. A process can be `online` and serving nothing, so the port is asked directly rather than inferred. That completes all eleven sections. catalogue.test.ts required both deleted files at import, so it could not even load. Its central assertion — "the store offers exactly what light leaves out" — has no meaning without a full list to subtract from, which is the point of the change. Replaced by two weaker but real checks: the store must not offer a core process, and every process it names must have a sidecar directory to run. The second catches the same typo the old one did without needing a manifest of everything; verified it holds for all 15 catalogue entries. app-store/pm2.ts starts a sidecar with `--only` against this file, which now holds core alone — so it can stop a plugin but cannot start one that was never written in. Marked `[open]` there rather than left to be discovered: appending a plugin's entry is the plugin system's job. Generated one in a scratch directory and required it with node: six apps, correct cwd on each, valid CommonJS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7280d13b93 |
group the barrel by core and plugin
Same 23 features, same order within each group, split by a blank line and a heading. It mirrors schema.ts, where the plugin tables are commented out. The difference between the two files is written down rather than left to be inferred: these lines are NOT commented, because doing so would break nothing at runtime — hono.ts mounts no plugin router, so none of it is reached — but tsgo checks every file under src/ whether it runs or not, and the plugin sidecars still import these symbols. They stay until each plugin is extracted. No export changed. Verified: same set of re-exported features before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fbb6d6c78c |
the agent sidecar crashed on boot without the email plugin
user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.
The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
escaped this function, rejected the top-level await, and exited the process into
exactly the PM2 restart loop the comment below says it exists to avoid." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.
Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.
Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).
The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3cb39662b5 |
desktop is a plugin too
Its sidecar, officer-vnc, was already excluded from the light profile, so calling it core was only the capability kind saying `execution` — which is about who may reach it, not whether a light install runs it. Unmounted the same way as the other twelve: `/desktop`, its capability's api and ws claims, the `desktop` websocket handler and its upgrade route. Implementation untouched. Also reverts a mistake from the previous commit. I had commented entries out of WSData's `provider` union and left `upgradeWs`'s parameter type listing them, which would have been a type error the moment either was used — and one I cannot see here, since node_modules is empty and tsgo does not run. Those unions describe possible values rather than what is served, and neither is a registration. Only registrations are commented now, which is what was asked for in the first place. Totality simulated again: 33 live mounts, 5 websockets, zero problems. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ffca309a77 |
switch off every plugin router, pending extraction
Twelve capabilities unmounted: gitea, music, photos, jellyfin, memos, calendar,
email, notify, transmission, soulseek, invoices, wallet. Mounts commented in
place, implementation untouched on disk, same as vault and the browser relay.
Each needed its capability's `api` claim commented in the same change.
assertCapabilityTotality check 2 refuses to boot on a capability claiming a
prefix nothing mounts, so unmounting alone would have stopped the server
starting — the opposite of what happened with vault, which is exempt.
music also claims two websockets. Its `ws: ['cliamp', 'cliamp-audio']` claim, the
two handlers in server.tsx and the provider union entries all had to move
together: check 3 fails on a served socket nothing claims, check 4 on a claimed
socket nothing serves.
calendar was the awkward one and the mount list was not where it lived. Four of
its six doors are not in the routes table:
honoServer.route('/dav', davSyncRouter) the sync door, top-level, not /api
honoServer.all('/.well-known/caldav') RFC 6764 autodiscovery
honoServer.all('/.well-known/carddav') the same for contacts
four routes in server.tsx /dav, /dav/*, and both well-knowns
and the two that ARE in the table carry trailing comments, which is why the first
pass silently missed them and left /api/caldav and /api/dav mounted with their
claim gone — exactly the boot failure this commit is about.
/vpn deliberately stays. It reads as a plugin (kind 'app') but it is OffTail
enrollment forwarding to officer-headscale, which is core because the tailnet is
the perimeter.
Verified by simulating assertCapabilityTotality against the post-change files
rather than trusting the edits: parse hono's live mounts, the registry's live
claims, server.tsx's live ws providers and totality's two exempt lists, then run
all four checks. It reported the two stale caldav mounts, which is how they were
found. Zero problems now, 34 live mounts, all core.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1eecc400a7 |
switch off Vaultwarden's routers, pending extraction into a plugin
Same treatment as the browser relay: mounts commented, code left on disk. Its
tables were already commented out of the schema earlier tonight, which is what
made this necessary — /api/vault was mounted against tables db:push no longer
creates, so a fresh core install shipped an endpoint that could only fail with a
Postgres "relation does not exist".
Vaultwarden is not one mount. Eight places had to go, and grepping for `vault`
found them only because several are not named after a router:
hono.ts /api/vault the authenticated reverse-proxy
/vaultwarden the unauthenticated one for the browser extension
VAULT_ONLY_PREFIXES loop /identity, /notifications, /icons, /events
the isBitwardenClient diverter an /api/* middleware that hands Bitwarden
clients to the vault router before anything else sees them
./api/vault/sidecar-server a SIDE-EFFECT import capturing the sidecar's port
UNPROTECTED_API_PREFIXES the '/vault' entry
server.tsx the 'vault' ws provider, its handler, and the notifications upgrade route
The side-effect import is the one worth naming: it registers a sidecar listener
and appears in no route table, so nothing about unmounting the routers would have
stopped it running.
No capability registry change, unlike browser and task-logs. Vaultwarden is
exempt from totality on both halves — EXEMPT_API_PREFIXES has '/vault'
("Bitwarden protocol clients authenticate to Vaultwarden, not to Officer") and
EXEMPT_WS_PROVIDERS has 'vault'. So nothing claims it and nothing breaks by
unmounting it. I said the opposite before checking; the check is what settled it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
595dd082a7 |
delete Task Logs
A complete read path over a table nothing could write to. task-logger.ts exported createTaskLog, appendToLog and finalizeLog, and none of the three was called anywhere in the tree — so `task_logs` could never gain a row, and the screen was permanently empty for everyone. The read half was fully wired: mounted router, capability claim, dock icon, two routes and a page-title rule. That is why it looked alive. Gone, in the order it was reached: Screens/Dashboard/TaskLogs/ the screen App.tsx /task-logs and /task-logs/:id Dashboard/index.tsx the export Layout/Dock.tsx the 'Logs' icon, and ScrollText with it state/usePageTitle.ts the title rule api/task-logs/task-logs.ts the router, and its mount in hono.ts api/task-logger.ts 101 lines of orphaned writer officer_db/src/operations/ the directory officer_db/src/schema.ts the export line officer_db/src/types.ts TaskLogSelect / TaskLogInsert capabilities/registry.ts loses '/task-logs' from the `tasks` capability's `api` AND `routes`. The api half is not optional: assertCapabilityTotality check 2 refuses to boot on a capability claiming a prefix nothing mounts, so unmounting the router while leaving the claim would have stopped the server starting. db:push now creates 21 tables, down from 43 at the start of the evening. officer_db/src/operations was one of the two lopsided directories the schema/queries merge exposed. integrations/ is the remaining one, and it is legitimate — it spans server and user-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f9cd0a798a |
drop two dead tables: queue_jobs and terminal_containers
Neither had a reader or a writer anywhere in the tree. db:push created them on every fresh install and nothing ever touched them. queue_jobs is from when the background job engine kept its state in Postgres; it works on files now (src/servers/queue/storage.ts). terminal_containers held a docker id and port per user, from the architecture where every account ran in its own container — gone, as data-path.ts already records. Their types went with them: QueueJobSelect/Insert and TerminalContainerSelect/ Insert were unreferenced too. db:push now creates 22 tables. operations/schema.ts keeps task_logs and a note about what left and why, along with the thing still wrong there: it has no queries.ts, because task_logs is reached as `schema.taskLogs` from src/servers/api/task-logger.ts, past this package's boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
288b4bf683 |
each feature declares its own exports; the barrel is one line per feature
src/index.ts is 47 lines and reads as a list: `export * from './agent-panels';` and 22 more, under `db` and `schema`. It was 297 lines of hand-written named exports. What a feature exports now lives in <feature>/index.ts, beside the schema and queries it describes. Adding a query function is one file in one directory rather than that file plus a list three levels up that nothing enforces — a function missing from that list was invisible to all 107 consumers while existing and compiling perfectly. The old file had drifted in the ways a hand-maintained list does: twelve features were listed twice because values and types were separate statements repeating the path, soulseek three times, two features used an inline `type` specifier instead, and `db` and `schema` — the package's most fundamental exports — sat at line 270 with notify, types and app-store appended after them. The surface is byte-for-byte the same set. Checked rather than asserted: 247 exported names before, 247 after, no missing and no extra. The per-feature index files carry the same named lists the barrel did, so `export *` widens nothing. `operations` is deliberately not in the list, and the root file says why: it has a schema and no queries, its task_logs is reached as `schema.taskLogs` from src/servers past this package's boundary, and its other two tables are read by nothing at all. Verified: every file in the package parses, every relative import resolves to a real file, and all 107 consumers still parse. Not typechecked — empty node_modules, frozen installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4cebae1c85 |
schema barrel: core tables only, plugin tables commented
db:push now creates 24 tables instead of 43. The 19 belonging to sidecars a light install does not run are commented out in schema/index.ts, kept as the record of what each table is called and which file defines it. Core is ecosystem.light plus officer-headscale, plus the app store and service_connections — app-store/effects.ts reads the latter, so it is core however few plugins exist. Commented: email, music, notify, dav, photos, jellyfin, invoiceshelf, soulseek, vault, wallet. The reason this is only a barrel edit is worth writing down. drizzle.config.ts points `schema` at schema/index.ts, so that file is drizzle-kit's view of the schema — but it is NOT the runtime's. Every query imports its table object from a schema file and calls db.select().from(table), and nothing anywhere uses drizzle's relational API (db.query.X), which is the only thing the `schema` passed to drizzle() in db.ts is for. So a commented line removes a table from the database without removing a line of code. That only held after moving nine query modules off the barrel and onto their own schema file — email-accounts, music, notify, photos, jellyfin, invoiceshelf, soulseek, vault and wallet all imported their tables from '../schema', so commenting the barrel would have broken the query module, then its export, then its consumers. dav already did it the right way. With that done the cascade is gone: everything still compiles, and the tables simply are not created. Two corrections to what was asked, both checked rather than assumed. The query barrel (src/index.ts) has no bearing on db:push — drizzle never reads it — so commenting it would not have kept a single table out of the database. And vault is not plugin-only from the platform's side: /api/vault is mounted top-level in hono.ts for the Bitwarden client, and api/vault/router.ts imports getVaultTokens. Its TABLES are out, but that route still exists and will fail against them. Not typechecked — empty node_modules, frozen installs — and db:push was not run, since there is no database here. Every file in the package parses, as does everything in the tree importing officerdb, and nothing outside the package reached a plugin table through the exported schema namespace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
c5adb4aa08 |
the anthropic proxy binds PORT + 1
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts, which binds it, and three others that guessed the same constant to find it. It is the only sidecar that binds a fixed port, and that part is a real constraint rather than an oversight. Every other one binds `port: 0`, lets the kernel choose and reports back over the registration socket — which works because their consumer is the platform. The proxy's consumer is `claude`, spawned by a different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask what port the proxy landed on. Two processes with nothing between them have to agree in advance. So the number must be predictable, but it need not be 5051 — a value chosen against nothing, in the registered range, free to collide with anything the owner installs later. The symptom of that collision would have been chat failing while the rest of the platform looked healthy. PORT + 1 keeps the predictability and drops both the constant and the variable. Nothing to set, no second number to keep in agreement with the first, and the pair moves together when the install moves. Also corrects .env.example, which said the proxy "holds the API credential, which lives in the host env". It does not. The upstream credential is the OAuth token claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent presents is the proxy's own generated secret. Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import it; every edited file parses. Still not typechecked — empty node_modules, frozen installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c7f52ab77 |
PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.
It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.
It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.
It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.
server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.
Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e72cae4830 |
one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one question, each defensible where it was written and none of them visible from the others: server.tsx and 19 sidecars 5000 a default from before there was an installer user-instance.ts 9010 what scripts/setup-old/setup.sh really wrote .env.example 9000 what we told people to write 5000 goes first because macOS binds it — AirPlay Receiver has owned it since Monterey, so a dev server there fails to bind or gets shadowed by something that answers. All 22 sites moved together, which is the point. Changing the app alone would have turned a consistent-but-wrong default into a split one: the app on 9000 while nineteen sidecars still dialled 5000. 9010 was the interesting one. It was the only value that ever matched a real machine, because it is what the old installer wrote — and it was in the single file whose disagreement would have broken chat alone, with nothing else looking wrong. Its own comment records the same bug being fixed once already, within the file, by a change that left it disagreeing with everything outside it. Note what these defaults actually are: the sidecars bind nothing. user-instance.ts has no listener at all — it builds ws:// and http:// URLs that both address the app's single listener. So every one of these numbers is a guess at where the app is, for a value that .env always supplies. Worth removing rather than aligning, which is a separate change. Not typechecked (empty node_modules, frozen installs). Every edited file parses under `bun build --no-bundle`; the pm2 profile loads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3bc06bba3c |
spell the root derivation as resolve rather than dirname
resolve(process.cwd(), '..') instead of dirname(process.cwd()). Identical on every input — checked including trailing slash and filesystem root — and it reads as the path arithmetic it is. resolve was already imported here for SEED_PATH. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
040ea41dbc |
per-user linux accounts are not optional any more
OFFICER_OS_USERS is gone. The platform behaves as it always would have with the flag on, and there is nothing to enable. Six conditionals, five of which were dead weight — provisionOsAccount, deprovisionOsAccount and the create/delete paths each opened with an early "not enabled on this server" return, and the API told the frontend whether to render the Linux controls at all. Those go, along with the 'disabled' DeprovisionResult stage, which nothing can produce now. The sixth is the one with teeth. assertSecretsClosed opened with `if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when the feature is off, so an existing install is unaffected until the owner opts in". It is now unconditional: the server refuses to boot while any .env in the project root is group- or world-readable. A member's shell reading .env and printing JWT_SECRET was confirmed exploitable when this check was written, and a prerequisite that only holds when somebody remembers to set a variable is not a prerequisite. Nothing to remove on the environment side — the flag was never in .env.example or in the setup script. Not typechecked: node_modules is empty in this tree and installs are frozen, so tsgo could not run. All six files parse under `bun build --no-bundle`, and the changes are deletions of dead branches plus one removed early return. Formatted with prettier 3.9.6 via bunx rather than the pinned resolution, for the same reason; its one unrelated reformat was reverted by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a64d5610e6 |
officer-setup section 5: Postgres, and only Postgres
The original offered five containers. Of those:
Postgres the only database Officer has — account, passkeys, settings,
dashboards, email accounts, the queue. Required.
Redis not referenced anywhere in the platform. No import of the client,
no environment variable, no mention; the only "redis" string in
src/ is the word "rediscover" in a comment. Dropped. (It is still
in package.json and comes out in the dependency pass.)
SearXNG zero references anywhere. Dropped. If it ever arrives it brings
its own compose file and its own Redis with it.
Mailhog a development convenience, offered separately rather than here.
Nginx PM a deployment choice — Caddy, Traefik, nginx or the tailnet — and
not something a setup script should pick.
Provisioned into $OFFICER_ROOT/dockers/postgres/, the same convention the app
store uses: one directory per service, the compose file in it, relative bind
mounts so the data sits beside the compose file.
Bound to 127.0.0.1, deliberately and with the reason in the compose file itself.
Docker publishes ports by writing iptables rules underneath ufw, so "5432:5432"
is reachable from the internet whatever the firewall reports — the same mechanism
the machine-setup firewall section exists to close. The platform runs on this
machine, so loopback is all it needs.
The password lives in a 0600 .env beside the compose file rather than inside it,
so the compose file can be read or copied without carrying a credential. A second
run reuses it rather than minting a new one, which would leave the container and
the URL disagreeing.
Readiness is waited for rather than assumed: Postgres initialises its data
directory on first start, and db:push against a database that is still starting
fails in a way that reads as a schema problem.
Choosing an existing database checks the URL but does not insist on it — the URL
may be right and the database not yet started, and refusing to continue over that
would be worse than saying so.
Also carries the whitespace fix for the comment removed in the previous commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
911b79b6a2 |
drop a comment describing a machine that no longer exists
paths.ts carried a parenthetical explaining that the development machine had the layout inverted — the project inside ~/dockers/officer.dev/, so the root derived to officer.dev and the app store's directory came out as a dockers inside a dockers. That machine is gone. The project sits at ~/officerdev/platform, which is the clean shape the comment said new installs would get. Anyone reading it now goes looking for a directory that is not there and comes away unsure whether the derivation can be trusted. The rule above it is unchanged and is the whole contract: data/ is a direct child of the root, and OFFICER_ROOT is dirname(DATA_PATH). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cec8fbe57e |
the acl check could not fail, because sudo drops DATA_PATH
Review of
|
||
|
|
76cd7c20bf |
sever the ACL as well as the ownership
severMemberTree reassigned the tree and left the access-control entries behind. confineUserTree
grants each member a named ACL on their whole tree — u:<uid>:rwx plus a default: copy — and
chown does not remove them: they are xattrs rather than ownership, and they record the uid
numerically.
Measured before this change: after chown -h -R to the service user, user:<uid>:rwx was still
present on the directory, on its children and in their defaults. The tree read as the
platform's while still granting the freed uid read and write on every byte, so the next account
allocated that number would inherit the previous member's home, keys, credential and container
storage — the hazard this file exists to prevent, reached through a door that find -uid cannot
see.
Now chown then setfacl -R -P -b. Proven on a scratch tree: owner 1001 with five entries naming
1001 becomes owner 1000 with none.
-b rather than removing the member's entries alone, because the service user owns all of it
afterwards and "no ACLs" is cheaper to verify than "no ACL naming one id". -P is already the
default for a recursive setfacl — verified, a symlink out of the tree was not followed — and is
stated for the same reason the chown above carries -h: a member chooses what their symlinks
point at, and this argv should not rest on a traversal default holding.
Found by running assert-uid-free.sh against a real tree; the spec and the checker had the same
blind spot and were corrected in
|
||
|
|
46799dada8 |
deprovision a member's linux account when the platform account goes
Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.
The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.
Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).
reap terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
final count that must be zero or the account is not released.
chown fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
it. The range is still captured first, because userdel removes the /etc/subuid entry and after
that nothing on the machine remembers what it was. It is returned on every path including the
failures, and logged as the exact assert-uid-free.sh command line.
Two guards the spec did not ask for, both pure and unit-tested:
guardDeletable ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
the platform would have confined, and uid >= 1000. Without it `userdel root` is one
bad users.osUser away and nothing else in the sequence would object.
guardMemberTree the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
database row and the result is the argument to a recursive chown.
chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.
destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.
deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.
NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f5f509a99d |
scripts/setup is the initial install, nothing else
Two of the eight did not belong. cleanup-desktop.sh is the teardown — the inverse of an install, not part of one. provision-user-dirs.ts runs per account at invite time, on a machine that is already set up. Both are back at the top level, with their `../` derivations and usage strings put back. What is left is what a fresh machine runs once: the two installers (setup.sh, setup_mac_light.sh), the two things setup.sh calls (setup-dockers.sh, setup-desktop.sh), and the two files they deploy — starship.toml, copied to ~/.config, and officer-set-display.sh, which setup-desktop.sh installs to ~/.local/bin as a login-time mode setter. The last one is not a setup script and does not read like one; it is here because it is install payload, same as the toml, and setup-desktop.sh loads it by `$(dirname $0)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9c353f5f0d |
move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index, prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree). The move is not just a rename. Three of these derive the repo root from their own location: setup.sh:51 PROJECT_DIR="$(dirname "$SCRIPT_DIR")" setup_mac_light.sh:51 same cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env" Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the real file. All three are now `../..` with a comment saying why the level matters. provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught. Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both in the same paragraph. The host one is now spelled with its full path at those sites. Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under DRY_RUN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec1997fd0e |
a chat with no chosen directory runs in the caller's own home
The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat sessions formed their own Claude project group instead of cluttering the home. It is a sibling of the home, and confineUserTree makes every sibling the platform's at 0700 because the others are attachments and email_accounts. So it was unreachable for a member: the first live member turn started there and every Bash call failed on its own working directory before doing anything. A per-member copy inside each home fixed the symptom and left two rules to remember. The owner chose one rule instead — the account's own home, whoever they are — and accepted the trade knowingly: /chat sessions now share a project group with anything else run from that home, which was the reason the dedicated directory existed. Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd, ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it. Existing directories are untouched and their transcripts stay where they are — Claude groups by cwd, so the owner's old /chat history remains under its own project slug rather than moving. The UI labels move with it: the default group now reads "home" rather than naming a directory that no longer has a role. ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by general_chat_sessions being email-derived, which is now gone — but the distinction it encodes is real (the email says who, the home says where), so the comment explains that instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c84c74c91 |
give a member a chat cwd they can actually enter
host captured the first live member turn: uid 1001, nine env vars, zero ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the allowlist both held. One defect. The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of the member's home, which confineUserTree deliberately makes the platform's at 0700 because the other siblings are attachments and email_accounts. So the turn ran in a directory the member cannot enter, and every Bash call failed on its own cwd. The agent reported its shell as broken, which was true. A member's default is now ~member/general_chat_sessions, created as them through runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The owner's path does not change, and the sibling stays 0700 — loosening it would trade a broken shell for an open directory holding attachments and mail. 29 said this path is email-derived and therefore stays email-derived. True, and it did not follow that it is usable: an email-derived path under DATA_PATH is precisely the set a member is locked out of. Splitting identity from filesystem path was right; assuming the identity side was inert was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2a8f0049a3 |
handle OSC 52, so "press c to copy" reaches the user's clipboard
xterm.js does not handle OSC 52 unless something registers for it, and nothing did. A program offering "press c to copy" emitted the sequence and it vanished, so its confirmation was true about having sent it and false about anything arriving. Found while signing a member into Claude Code on the production host: its first-run login prints an OAuth URL too long to read off a wrapped pane and offers to copy it, "(Copied!)" appeared, and the clipboard was untouched. The URL had to be recovered by running claude under tmux on the server and reassembling it from the captured pane — which is not a thing a member can be asked to do, and first-run login is every new member's first five minutes. Writes only. A lone `?` in the data position is a read request — a program asking the terminal to hand over whatever the user has copied — and it is deliberately not answered: a shell should not be able to exfiltrate the clipboard of the person watching it. The clipboard API needs a secure context and generally a user gesture; the keypress that caused the sequence is that gesture. A refusal is swallowed rather than thrown, since a copy that does not land is the status quo rather than a reason to break the pane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
532ad15ac1 |
drop the import the gate left behind
|
||
|
|
c59df4f866 |
let members use chat
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.
They were right for the day they stood. A turn spawned claude as the OWNER and
every transcript path resolved through the owner's home, so a granted member
would have read the owner's sessions and run an agent as them.
What replaced them, rather than what deleted them:
the turn runs as the member spawnClaudeAsMember through sudo setpriv,
proven against a real account by reading file
ownership rather than trusting the process
the credential is theirs --reset-env plus an allowlist, so the owner's
proxy variables cannot cross
the transcripts are theirs ChatIdentity carries a home from resolveHomeDir
and claude-sessions cannot invent one
the sessions are theirs every session records its owner and all six
sidecar commands refuse a mismatch
Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.
registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c5522700ff |
compare the binary as a string, and prove the uid by file ownership
host ran the live test against green. Two results. SETPRIV WORKS. The privilege drop lands on the member and the SDK spawn survives it, so the design does not change shape and everything layered on the hook stays. That was the last question that could have moved the architecture. THE BINARY CHECK REFUSED A BYTE-IDENTICAL PATH. sameFile used realpathSync, which has to stat inside a 700 home the platform is `other` to, so it threw EACCES and the catch turned that into "not their binary" — every member turn refused forever, the moment the gates moved. Failing closed was the right direction and it made the feature impossible rather than unsafe. Now resolve(command) !== claudeBinIn(run.home). Both operands are computed by the platform from the same function, so string equality establishes exactly what the check is for and needs no access to their home. sameFile and its tests are deleted: a helper kept for a case that cannot arise is a trap for the next reader. The realpath version was defending against an upstream that normalises paths, and there is no such upstream — the platform controls both ends. The fact that decides this is that the check runs in the PLATFORM process, not the member's, and neither of us stated it until EACCES did. THE TEST WATCHED THE WRONG PROCESS. child.pid is sudo, whose real uid is legitimately the service user's until it execs down through setpriv, so asserting on it fails on a working drop. Split: --version through the hook proves their binary ran, and a second spawn creates a file in their home whose owner the test stats. Nothing self-reports, and a process cannot forge the uid that owns a file it created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |