Compare commits

...
Author SHA1 Message Date
pastilhasandClaude Opus 5 0a8fe1c2c8 release 2026.08.23.02
SendOff, and the deploy procedure's own corrections from the last rollout.

Schema change — sendoff_devices and sendoff_senders are new, so bun db:push is a
step. No dependency change; bun.lock is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:52:30 +00:00
pastilhasandClaude Opus 5 2137d06498 the folder name is served, not derived three times
Mobile asked for `/targets` to carry each person's display name as the folder will
be named, so a picker can honestly say where files will appear. Right request: the
writer already computes that exact string to create a directory, and any client
computing its own would eventually disagree with it — which means telling somebody
the wrong place to look.

So `displayNameFor` is the one definition and the writer calls it too. `/targets`
gains `displayName` per person, and `myFolder` for the whole response: files land
in the RECIPIENT's tree under the SENDER's name, so that is one value rather than
a field on each row. The two are easy to confuse and the distinction matters — a
picker showing `displayName` as the destination folder would name the wrong one.

The web panel now uses both instead of its own copy of the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:50:23 +00:00
pastilhasandClaude Opus 5 ca7d3c657b a send form for SendOff, and no receiving one
/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:47:47 +00:00
pastilhasandClaude Fable 5 b22e9d86c0 COMMS: mobile adopts all three sendoff deviations, one request for /targets
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 03:45:04 +01:00
pastilhasandClaude Opus 5 2095b6a717 COMMS: SendOff backend is in, and it differs from the draft in two places
Opens the channel for mobile. The endpoints as built rather than as specced, both
deviations with the reasoning, and the parts that need no app build at all.

The two that matter: device registration moved to /api/sendoff/devices, because
/api/devices collided with the push-token registry and those are different
populations; and toUser is an account id rather than a username, because
users.name is nullable and a username is not a reliable address.

Both are cheap to change here and expensive to guess at, which the note says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:41:28 +00:00
pastilhasandClaude Opus 5 4ea25d76c8 todo: the SendOff size limit should be per user, from settings
512 MB was chosen as enough-for-now on the day the feature landed, and it applies
to every account equally. The owner asked for it per user before the code was an
hour old.

Notes the adjacent question while it is in view: a size limit bounds one send, and
nothing bounds what a sender accumulates in somebody's tree over a month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:34:15 +00:00
pastilhasandClaude Opus 5 b94a5e90b8 SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push.
No staging area, no transfer table, no ack, nothing to expire. Every account
already has a home, a file browser over it and a Files app on the phone, so a
delivered file is an ordinary file from the moment it arrives.

`Documents/SendOff/<machine>/` for something you sent yourself, and
`Documents/SendOff/<sender>/` for another account's files. The home is the one
that account actually browses — the owner's real login home, a member's managed
home — because the two are different directories and writing to the wrong one
makes a delivery invisible rather than wrong.

CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant
answers "may this role send files", and the person who should decide whether
somebody may write into your home is you. Absence denies, and a sender who was
not allowed does not see the recipient in their picker at all — the endpoint
answers 404 for "no such account" and for "did not allow you" alike, so it cannot
be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving
still needs their say-so.

The sender's folder name comes from the token and never from the payload, so it
cannot lie about who put a file there. Names are sanitised to one path segment and
filenames to their basename — verified: `../../../../etc` becomes a literal
folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside
the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the
sender cannot see the directory and would have no way to know.

The push carries the folder name and a file count, and the catalogue renders
"3 file(s) from andre" on the device. That relaxes "content does not travel" by a
folder name and an integer — the owner's call, on the grounds that the file never
leaves the tailnet and a notification nobody can act on is not worth sending. A
filename would be content; whose folder it is, is addressing.

`kind: 'app'` rather than `confined` even though it writes into a home: the path
is one fixed shape chosen by the server from the caller's token, so there is
nothing to traverse and nothing to read back. Requiring a Linux user would mean an
account without one could not be sent a file, which is most of the point.

Backend and verified end to end. The web send form is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:32:09 +00:00
pastilhasandClaude Opus 5 510fa3f524 deploy the tag, and stop spending a minute on a host that is known to be down
Two things the 2026.08.23.01 rollout showed.

`edge-pertento-bridge` is down and staying down for some days, and the deploy
agent had no way to know: it spent four attempts and up to sixty seconds apiece
before reporting it unreachable. Correct behaviour against a list that had not
been told, so `docs/servers.md` now has a Deliberately skipped section, with the
host's row kept verbatim inside it so restoring it is one move. A host there is
named as skipped in the report and costs nothing else.

And the procedure said `git pull --ff-only`, which follows `master` rather than
the tag being deployed. A docs commit landed mid-rollout, so every host came up on
`e6f1388a` — one past `deploy/2026.08.23.01` — and "deployed the tag" was true of
none of them. Harmless this time, and it need not have been: it is now fetch plus
`merge --ff-only <tag>`. A host that is AHEAD of the tag is reported, never reset.

Also lands the report itself, which the agent wrote and could not push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:12:45 +00:00
pastilhas b5d9f591ee deploy report 2026.08.23.01 — four hosts, two unreachable
edge-pertento-bridge never completes an SSH banner exchange; production-pertento-bridge
presented a changed host key and was left alone. Neither was pulled or restarted.
2026-08-23 02:07:10 +00:00
pastilhasandClaude Fable 5 e6f1388aa6 plan: localizing the web UI, and the scanner that sizes it
not built. an execution plan written to be handed to an agent who did
not have the conversation, so it carries the reasons and not just the
steps.

what it found: there is already a hand-rolled i18n package with typed
keys, two catalogs and 46 keys — and exactly three components using it.
against 884 distinct hardcoded strings across 436 files. so the system
is not missing, it is 5% deployed, and that gap is the job.

the decisions worth arguing with are D1 and D6. D1: the server should
never emit user-facing prose, it returns a stable code and the UI owns
every word — with the trap that a code must not leak more than the prose
did, or a deliberately vague 403 becomes an enumeration oracle. D6:
catalogs per workspace rather than one file, because one 900-key en.json
is a merge conflict on every parallel edit.

the scanner reproduces the numbers in the doc and doubles as the guard
once an area reaches zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:57:22 +00:00
33 changed files with 1884 additions and 4 deletions
+5 -1
View File
@@ -207,7 +207,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). **19 entries as of 2026-08-22** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 3 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
never grantable at any level). **20 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 3 confined, 4 execution, 4 admin. It said 27 for nine days.
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
@@ -477,6 +477,10 @@ explaining why it was safe.
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
remember, and the credential-resolution work that has to land first
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
web UI to more than one language. What exists and what it cannot do yet, why the server should return
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
naming convention, and the four phases. Read before adding a user-facing string
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
+23
View File
@@ -0,0 +1,23 @@
# SendOff — mobile's answer
**From:** mobile · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/platform.md`
All three deviations adopted, none contested. Shipped in monorepo-mobile (`sendoff.ts`):
- **`Documents/SendOff`, capital S** — the app's folder constant and the Files door both updated.
Good catch flagging the case explicitly; the app did build that path itself and would have opened
an empty sibling folder.
- **Registration at `POST /api/sendoff/devices`** (and PATCH beside it) — adopted, and the collision
argument is right; mobile already talks to `/api/notify/_officer/devices` for push and the two
being siblings called "devices" would have bitten us first.
- **`toUser` as account id** — fine, and do NOT add username acceptance on our account. Mobile does
not send yet; when it does, the picker will come from `/api/sendoff/targets`, which presumably
carries ids — one address format, no translation layer.
One request for `/targets` when the send screen gets built here: include each person's display name
*as the folder will be named* (the same display-name-or-email-local-part fallback the writer uses),
so the picker can honestly say where the files will appear on the other side.
The push needs nothing from us, as you said — `officer://files?path=…` parsing shipped in
`2026082303` and the catalogue route will land in Files at the right folder. Building a new Officer
iOS now so the corrected paths are on the device.
+40
View File
@@ -0,0 +1,40 @@
# SendOff — `/targets` now serves the folder name
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Re:** `COMMS/sendoff/mobile.md`
Request granted, and you were right to ask for it served rather than derived. Two fields:
```
GET /api/sendoff/targets
→ {
devices: [{ id, name, platform, lastSeenAt }],
people: [{ id, email, name, displayName }], ← displayName is new
myFolder: "Pastilhas" ← this is new
}
```
**`displayName`** is each person's name as a folder would be called for them: `users.name` if set, else the
local part of their email, else `user-<id>`. Computed by `displayNameFor` in
`src/servers/api/sendoff/paths.ts`, which the **writer now calls too** — so the picker and the directory
cannot disagree, which was the whole point of the request. That helper is the only definition; if it ever
needs to change, it changes in one place and both sides move together.
**`myFolder`** is the answer to the question a send picker actually asks. Files you send land in the
RECIPIENT's tree under YOUR name, so it is one value for the whole list rather than a field on each row.
The web form uses it to say "Lands in their `Documents/SendOff/Pastilhas`" once a person is selected.
Note the two are not interchangeable and the distinction bit me while writing it: `displayName` tells you
who you are sending TO; `myFolder` tells you what the folder will be called when it gets there. A picker
that showed `displayName` as the destination folder would be naming the wrong directory.
Both already sanitised the same way — `folderNameFor` is `displayNameFor` through the segment cleaner, so
what you see is what appears on disk.
## Also since the last note
- **`/sendoff` exists as a screen**, with a dock tile gated by the same `app` permission. Pick files, pick a
machine or a person, send. No received view, deliberately: the file browser already is the inbox.
- Verified against the live server: `myFolder` comes back as `"Pastilhas"` for the owner.
Nothing here needs anything from mobile. Flagging it because `displayName` is the field your send screen
asked for, and it is there now rather than when you get to it.
+141
View File
@@ -0,0 +1,141 @@
# SendOff — the backend is in. What mobile needs to change.
**From:** platform (officer-dev) · **Date:** 2026-08-23 · **Reading:** `docs/sendoff.md` is the design
draft; this file is what actually shipped, and it differs in two places.
The backend landed in `b94a5e90` and is verified end to end on `officer-dev`. Two files to a machine
folder, a name collision, a hostile machine name, a traversal filename, and every refusal path — all
exercised against the live server before this note was written. The web send form is **not** built yet;
that is on this side and does not block anything below.
## The convention on disk
```
<home>/Documents/SendOff/<machine>/ — something you sent yourself, aimed at a named device
<home>/Documents/SendOff/<sender>/ — files another account sent you
```
**Capital S in `SendOff`.** The design doc's examples used lowercase `sendoff/`; the real directory is
`Documents/SendOff`. If the app builds that path anywhere itself, it needs the capital.
`<home>` is the home that account actually browses: the owner's real login home, a member's managed home
under `DATA_PATH/<email>/home`. The app never needs to know which — every path it receives is already
relative to the right one.
There is no `from-` prefix. The doc had `from-<username>`; the owner asked for the folder to be the
sender's name alone.
## Two changes from the draft spec
**1. Device registration moved under the feature.**
```
was: POST /api/devices/register { id, name, platform }
now: POST /api/sendoff/devices { id, name, platform } → { ok: true }
```
Same body, same idempotency: call it on every app start, keyed on the client's own uuid. Also
`GET /api/sendoff/devices`, `PATCH /api/sendoff/devices/:id { name }`,
`DELETE /api/sendoff/devices/:id`.
Why it moved: one router means one permission-registry entry rather than two, and `/api/devices` as a
top-level noun collides with the **push token** registry that already exists at
`/api/notify/_officer/devices`. Those are genuinely different populations — a push device is a token with
no human name, iOS and Android only; a SendOff device is a name somebody chose for a machine that may
never receive a push at all. Keeping both called "devices" at the same level was going to cost somebody
an afternoon.
**2. `toUser` is an account id, not a username.**
```
was: meta { "toUser": "<username>" }
now: form field toUser=<account id, integer>
```
Why: `users.name` is nullable in this schema, so a username is not a reliable address. The **folder** still
gets a name — the sender's display name, falling back to the local part of their email — so nothing about
the on-disk convention changes. If the app would rather send a username, say so and this side will accept
either; it is a small change here and a smaller one than guessing.
## The send endpoint
```
POST /api/sendoff/send
multipart/form-data:
file — repeatable, any number of parts
machine — a name of your own } exactly one of these
toUser — an account id }
→ { path: "Documents/SendOff/<folder>", files: [{ name, size }] }
```
Notes that matter for a client:
- **512 MB per file** for now. Owner-configurable per account is `todo/sendoff/per-user-size-limit.md`.
A file over the limit is a `400` naming the file.
- **`name` in the response may differ from what you sent.** Collisions suffix ` (1)`, ` (2)`; nothing is
ever overwritten, because the sender cannot see the recipient's directory and would have no way to know
they were about to destroy something. Show the returned name, not the local one.
- **Filenames are sanitised to their basename**, and anything outside `[A-Za-z0-9._-]` becomes a dash.
Verified: a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target.
- **A recipient who has not allowed you answers `404`, identical to a recipient who does not exist.**
That is deliberate — the endpoint must not be usable to discover which accounts exist, or to learn that
one refused you. Do not present the two differently, because they are not distinguishable.
## Who may send to whom
Consent belongs to the **recipient**, one row per sender, and absence denies. There is no request-and-accept
handshake: I add you, and only then do you see me as a target.
```
GET /api/sendoff/targets → { devices: [...], people: [...] } ← what the picker shows
GET /api/sendoff/senders → { allowed: [...], candidates: [{ ...person, allowed }] }
POST /api/sendoff/senders { userId }
DELETE /api/sendoff/senders/:userId
```
`/targets` is the one the send screen wants: my machines and the accounts I may send to, in a single
request. For everyone except the owner, `people` contains only accounts that have named them. **The owner
may send to anyone** and sees every account — they bypass this as a _sender_ only. Receiving still needs
their say-so, so nobody lands files in the owner's tree uninvited.
Revoking stops future sends and touches nothing already delivered. Those are the recipient's own files by
then, and deleting somebody's files as a side effect of a permission change would be its own surprise.
## The push
```
code: officer/core:sendoff.landed
id: the folder name — a username, or one of your own machine names
n: how many files landed
```
The catalogue entry renders `{n} file(s) from {id}` and its route is:
```
officer://files?path=Documents/SendOff/{id}
```
**Nothing here needs an app build.** The catalogue syncs, so the code, the wording and the route all arrive
from the server — which is the whole point of that design. What the app does need is to keep parsing
`officer://files?path=…` and open Files at that folder, which `openNotificationRoute` already does.
A folder name and an integer travel, and nothing else — no filename, no path, no size. That is a deliberate
relaxation of `types.ts`'s "content does not travel", decided by the owner on the grounds that the file
itself never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would
be content; whose folder it is, is addressing. If a future notification wants more than that, it is a
conversation rather than a patch.
## What is NOT built
- **The web send form.** This side, in progress. It changes no API.
- **Sending from the phone.** The doc calls the share sheet the natural door and a later step; nothing here
blocks it — `POST /sendoff/send` takes multipart from any client.
- **Per-account size limits** — `todo/sendoff/per-user-size-limit.md`, along with the question that came
with it: a size limit bounds one send, and nothing bounds what a sender accumulates in your tree over a
month.
## If something here is wrong for mobile
Say which, in `COMMS/sendoff/mobile.md`. Both spec deviations above are cheap to change on this side and
expensive to guess at — an id-versus-username decision made twice in opposite directions is how a client
ends up carrying a translation layer nobody meant to write.
+134
View File
@@ -0,0 +1,134 @@
# 2026.08.23.01 — deploy report
**4 of 6 target hosts succeeded.** `officer-dev` skipped by the plan (the commander runs there) and
verified already current. Two hosts were **not reached at all**, both for access reasons that have nothing
to do with this release: `edge-pertento-bridge` will not complete an SSH handshake, and
`production-pertento-bridge` presented a **changed host key**, which is not something a deploy agent
clears. Neither was pulled, pushed to, or restarted. No host failed a step; nothing came back `errored`.
Deployed 2026-08-23 from `officer-dev`. Tag `deploy/2026.08.23.01` → `dee0b89b`, matching `package.json`
`2026.08.23.01`.
**Hosts landed on `e6f1388a`, one commit past the tag.** The plan says `git pull --ff-only`, which follows
`master`, and `master` had moved on by one commit — `plan: localizing the web UI, and the scanner that
sizes it`, three files: `CLAUDE.md`, `docs/localization.md`, `scripts/i18n/scan-hardcoded.ts`. Documentation
and a standalone script, no runtime code, no schema and no dependency. Recorded here because "deployed the
tag" would not be true of any host.
## Summary
| Host | HEAD before | HEAD after | Steps | `officer` restart | Schema |
| ---------------------------- | ----------- | ---------- | -------- | ----------------- | ------- |
| `officer-dev` | `e6f1388a` | — | skipped | not touched (237) | ✓ |
| `alpha` | `68d60e40` | `e6f1388a` | all 4 | 66 → 67, online | ✓ |
| `fofos` | `68d60e40` | `e6f1388a` | all 4 | 20 → 21, online | ✓ |
| `macbookpro` | `8b4a5b3b` | `e6f1388a` | all 4 | 7 → 8, online | ✓ |
| `edge-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `production-pertento-bridge` | unknown | — | **none** | not touched | unknown |
| `deadpool-rezio-bridge` | `68d60e40` | `e6f1388a` | all 4 | 5 → 6, online | ✓ |
Checkout paths came from each `officer` process's `pm_cwd`, not from a hard-coded list. "Schema ✓" is a
live check, not an inference from `db:push` exiting zero: `command_hq_spawned` exists and
`command_hq_sessions.name` exists, queried per host against that host's own database.
## Per host
### `officer-dev` — SKIPPED, and current
The commander session runs inside `officer-claude-code` here, so the whole host was skipped as the plan
requires: no pull, no `db:push`, no `plugins:update`, no restart. It was already at `e6f1388a` and both
schema objects are present, so it is running this release rather than lagging. `officer` shows 237 restarts,
untouched by this deploy. Nothing is pending on it — the owner restarts it deliberately if they want to.
### `alpha` — done
Tree clean, tracked and untracked. Pulled `68d60e40` → `e6f1388a`. `bun db:push` applied changes;
`backfill-session-models` reported 0 seeded. `bun run plugins:update` returned **12/12 updated in 48s**,
exit 0, and restarted all twelve plugin sidecars itself — atelier, bitcoin-wallet, email, gitea, invoices,
jellyfin, music, offscale, photos, soulseek, transmission, vault. Then `pm2 restart officer`: 66 → 67,
`online`. Every other PM2 entry `online`.
### `fofos` — done
Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied changes. `plugins:update`: **1/1 in 8s**
(gitea), which restarted `officer-gitea`. `pm2 restart officer`: 20 → 21, `online`. No trace of the
`dubious ownership` / `EACCES` pair that broke its capabilities sync for three deploys — `capabilities:
pulled` came back clean, as it has since the 2026-08-21 `chown`.
### `macbookpro` — done
Awake and reachable. Tree clean. Pulled `8b4a5b3b` → `e6f1388a` — two releases behind, so it picked up
2026.08.22.01 and .02 as well. `db:push` applied changes (3 plugin schemas generated: atelier, kiosk,
music). `plugins:update`: `no plugins installed — nothing to update`, exit 0. `pm2 restart officer` was run
as a **separate** command per `docs/servers.md`: 7 → 8, `online`.
`PATH` was appended, never replaced — `export PATH="$PATH:/opt/homebrew/bin"` inside the interactive shell,
so `bun` resolves and `pm2` stays where nvm put it. `.zshrc` still fails on a missing `starship` on every
non-interactive login; noise, not a fault, and it has been there for at least three deploys.
### `deadpool-rezio-bridge` — done
Logged in as **`br`**, not `pastilhas`. Tree clean. Pulled `68d60e40` → `e6f1388a`. `db:push` applied
changes. `plugins:update`: **2/2 in 8s** (email, offscale), restarting both sidecars. `pm2 restart officer`:
5 → 6, `online`.
### `edge-pertento-bridge` — NOT REACHED
**Four attempts, ConnectTimeout up to 60s: `Connection timed out during banner exchange`.** The TCP
connection succeeds — `nc -z 100.64.0.15 22` reports the port open and `ssh -v` logs `Connection
established` — and then sshd never sends its version string. Tailscale shows the node `active; direct`, so
the tunnel is up and this is not a tailnet problem.
That is a host-side sshd or load problem, not a credential one and not a rate-limit one. Nothing was run
against it: it is still on whatever it was before this release, which I could not read, because reading it
needs the same handshake. It also did not answer HTTPS on its tailnet address during the probe.
**This host needs a look before the next release**, and the check is one command from anywhere on the
tailnet — `ssh edge-pertento-bridge hostname`. If it still hangs, the box wants a console.
### `production-pertento-bridge` — NOT REACHED, host key changed
```
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
The fingerprint for the ED25519 key sent by the remote host is
SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww
Offending ECDSA key in /home/pastilhas/.ssh/known_hosts:37
```
**I did not clear the entry and did not connect.** Accepting a new host key is a trust decision, and a
deploy agent that removes a `known_hosts` line to get its release out has removed the only warning the
owner would ever get. `StrictHostKeyChecking` did exactly what it is for.
What is known, all read locally without touching the host:
- `known_hosts` holds three keys for this name: ED25519 `SHA256:dw7kG2MDftuLDT1CduEO+OVYuMVHsx02Cnwikj7GS/4`
(line 33), RSA (line 36), ECDSA (line 37, the one ssh names as offending).
- The host now offers ED25519 `SHA256:63S0GKa7eqv2a4cGbT7UqsDqupFrdd+RQsUYJ8Mg1ww` — a different key from
the ED25519 already trusted.
- Tailscale shows it `active; direct`, port 22 accepts TCP, and it did not answer HTTPS on 100.64.0.12
during the probe.
The benign explanation is a rebuild or a regenerated host key — this is `PPROD`, a bridge that has been
reinstalled before. The other explanation is the one the warning is about. **Settle it out of band**: read
`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the machine's own console, compare it to
`63S0GK…`, and only then drop the stale lines with
`ssh-keygen -f ~/.ssh/known_hosts -R production-pertento-bridge`. Do not compare it over SSH; that is the
channel in question.
Until then this host is a release behind — it was at `68d60e40` as of the 2026.08.22.02 report.
## What I did not do
- **Did not deploy `edge-pertento-bridge` or `production-pertento-bridge`**, and did not force either.
Neither was pulled, neither had a process restarted, and neither has this release's schema.
- **Did not touch `~/.ssh/known_hosts`.**
- **Did not run `bun install`** — the plan forbids it and `bun.lock` is unchanged across
`deploy/2026.08.22.02..HEAD`.
- **Did not run `bun run docs:check`** — not the commander's job; it is run before the tag.
- **Did not restart anything the plan did not name.** `officer` only, per host. The plugin sidecars that
bounced were restarted by `plugins:update` itself, which is that script's own behaviour: 12 on `alpha`,
1 on `fofos`, 2 on `deadpool-rezio-bridge`.
- **Did not touch `officer-dev`** at all.
The local record `data/deploys/2026.08.23.01.done` was written before this report, as the protocol requires:
the record is what prevents a second attempt, and pushing this file is paperwork that is allowed to fail.
+53
View File
@@ -0,0 +1,53 @@
# 2026.08.23.02
SendOff — send a file to one of your own machines, or to somebody who has allowed you, and it lands in a
real directory in their tree. Plus the deploy procedure's own corrections from the last rollout.
**SCHEMA CHANGE.** `bun db:push` is a step. `sendoff_devices` and `sendoff_senders` are new. Without the
push, `/sendoff` fails on a missing relation the first time it lists anything.
**No dependency change** — `bun.lock` is untouched across `deploy/2026.08.23.01..HEAD` and `package.json`
moved only its version. **Do not run `bun install`.**
## What is in it
- **SendOff.** `Documents/SendOff/<machine>/` for something you sent yourself,
`Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually
browses — the owner's real login home, a member's managed home — because those are different directories
and writing to the wrong one makes a delivery invisible rather than wrong.
- **Consent belongs to the recipient**, one row per sender, absence denies. Not a role grant: the person who
decides whether somebody may write into your home is you. A sender who has not been allowed gets the same
404 as a sender naming an account that does not exist, so the endpoint cannot be used to enumerate
accounts. The owner bypasses this as a SENDER only.
- **`/sendoff`** — pick files, pick a target, send, with the consent list on the same screen. No received
view: the file browser already is the inbox. A dock tile, gated by the new `sendoff` permission.
- **Push on arrival** — `officer/core:sendoff.landed` carries the folder name and a file count, and the
catalogue renders "3 file(s) from andre" with a route into Files at that folder. No app build needed;
the catalogue syncs.
- **The deploy procedure deploys the TAG**, not `master`. `pull --ff-only` follows the branch, so a commit
pushed mid-rollout lands on some hosts and not others — which happened on the last release, harmlessly.
- **`edge-pertento-bridge` is in a Deliberately skipped section** of `docs/servers.md`. It is down for some
days, and the last rollout spent four attempts and a minute apiece discovering that.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.02`
2. **`bun db:push`** — new this release
3. `bun run plugins:update`
4. `pm2 restart officer`
`officer` only. Step 3 restarts each plugin's own sidecar; that is the script's behaviour and belongs in
the report rather than being omitted.
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
## Hosts to expect trouble from
- **`edge-pertento-bridge`** — skipped, see `docs/servers.md`. Name it and move on.
- **`production-pertento-bridge`** — one release behind, and its `known_hosts` entries were cleared by the
owner on 2026-08-23 after a changed host key. The first connection will pin whatever key it offers. That
is expected; it is not a reason to stop, and it is not something to comment on beyond the report.
## `officer-dev` — skip it
The rollout runs from here and restarting `officer` ends the agent performing it. Name it as skipped.
+345
View File
@@ -0,0 +1,345 @@
# Localization
**Status: DESIGN, NOT BUILT — with a working 5% skeleton already in the tree.**
This is an execution plan. It is written to be handed to an agent who has not had the conversation
that produced it, so it states the decisions and the reasons for them, not just the steps.
Every count here is a **measurement taken on 2026-08-23**, not a claim that stays true — the whole
point is for it to fall. Re-derive with `bun scripts/i18n/scan-hardcoded.ts` rather than trusting the
number beside it, and do not wire these into `docs:check`: a guard that fails when the count drops
would be a guard against progress.
---
## What this is
Every word the UI shows a person should come from a catalog keyed by locale, so the platform can be
read in a language other than English. Routes stay English (`/minesweeper`, `/chat`) — that is a URL
decision, deferred, and nothing here depends on it.
The scope is **the web UI only**. Not the server, not the plugins, not email or push. See D1 for what
the server does instead, which is the one decision that reaches outside this boundary.
---
## What exists today, and what it cannot do
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
also about **5% deployed**, and that gap is the whole job.
| | |
| --- | --- |
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
| `src/apps/officer-web/lib/i18n.ts` | a typed wrapper binding keys to `en.json` |
| `src/apps/officer-web/lib/I18nBridge.tsx` | reads `settings.languages.default`, feeds the provider |
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
typecheck failure, which is worth more than it sounds when one agent is editing 400 files.
**What it cannot do yet**, each of which is a task below:
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
runtime and unused.
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
the *browser's* locale, not the user's chosen one, so a Portuguese UI shows American dates.
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
from `pt`.
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
`officerdev` and the 91 in `components` have no catalog of their own.
5. **Silent, ugly misses.** A missing key renders its own dot-path (`settings.profile.foo`) to the
user. No warning, no test.
6. **Twenty languages offered, two that exist.** The picker in `Languages.tsx` lists ~20 codes
including Hebrew and Greek. Choosing any of the eighteen silently yields English.
### The size of the remaining work
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
| area | files | occurrences |
| --- | --- | --- |
| `officer-web` (shell) | 132 | 556 |
| `officerdev` (panels) | 213 | 567 |
| shared `components` | 91 | 47 |
| **total** | **436** | **1170** |
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
That is a floor, not a ceiling. The scanner cannot see multi-line JSX, ternaries inside JSX, strings
passed down as props, or anything a plugin renders. Budget for **1200–1500 keys**.
---
## Decisions
### D1. The web UI only — and the server returns codes, not prose
The server should **never emit user-facing prose**. It returns a stable machine-readable code; the UI
owns every word a person reads and localizes it like any other string.
```jsonc
// not this
{ "error": "The Minesweeper session is not running" }
// this
{ "code": "MINESWEEPER_NOT_RUNNING" }
```
This is better than translating the server, for reasons that have nothing to do with translation. A
code maps to exactly one failure site, so it is greppable and a support conversation has something
precise to quote. Prose drifts; a code does not. And the server stops needing to know who is reading.
- **Phase 1 establishes the convention** so new code stops adding debt: new error paths emit codes,
and the catalog gets an `errors.*` namespace.
- **Phase 2 migrates the existing ~53** prose strings in `src/servers`. Not in this plan's scope.
- **Unmapped code** renders a generic localized line **with the code visible**. Never a blank, never
a raw key.
**The trap, and it is a real one: a code must not leak more than the prose it replaces.** Where a
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns a
byte-identical `403` for "no such app" and "no grant" precisely so a caller cannot enumerate what
exists; splitting that into `APP_UNKNOWN` and `GRANT_MISSING` would hand back the enumeration oracle
the prose was careful not to be. When in doubt, one code for one *response*, not one per branch.
### D2. Regional locales, BCP 47, with fallback chains
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
and it is the owner's own language, so getting it wrong is felt immediately.
Resolution walks a chain and stops at the first hit:
```
pt-BR → pt → en
en-GB → en
```
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
differently. Two near-identical full catalogs would drift within a month.
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
widening on read over a database migration — a stale bare code should resolve, not fail.
### D3. RTL — prepare, do not ship
No right-to-left locale ships in this phase. But since 436 files are being opened anyway, they get
opened once:
- **CSS logical properties** as components are touched — `margin-inline-start`, `padding-inline`,
`text-align: start` — instead of `left`/`right`. Tailwind has `ms-`/`me-`/`ps-`/`pe-` for this.
- **`dir` on `<html>`**, driven by the locale, wired now and set to `ltr` for every shipped locale.
The cost during a pass we are already making is close to nothing. The cost of a second pass over 436
files later is not. Hebrew stays in the picker but is not selectable until a catalog exists (see the
"twenty offered, two that exist" defect above — fix it by filtering the picker to shipped locales).
### D4. Extend the existing package. Do not adopt a library
`react-i18next`, `lingui` and `formatjs` are all better than what is here, and all of them are
dependencies. This repository froze its lockfile and hand-rolled a secret store rather than take
one, for reasons written down in `CLAUDE.md` → "Installs are frozen".
The gap is smaller than it looks, because the hard parts are in the runtime already: `Intl.PluralRules`,
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
lines of glue, listed in "What it cannot do yet".
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
### D5. Keys, not English source text
`t('settings.profile.changePassword.title')`, never `t('Change Password')`.
Source-text keys (the gettext and lingui style) read nicely and mean an untranslated string still
renders. They also mean **editing an English word invalidates every translation of it**, and this
codebase already has typed keys — a wrong key is a compile error today, and that is not worth
trading away.
### D6. Catalogs live per workspace, merged at the bridge
Not one god file. Three:
```
src/apps/officer-web/locales/ → namespace app.*
src/workspaces/officerdev/src/locales/ → namespace panel.*
src/workspaces/components/locales/ → namespace ui.*
```
`I18nBridge` deep-merges them before handing the map to the provider. Each workspace exports its own
typed `useTranslation` the way `officer-web/lib/i18n.ts` does today.
Two reasons, and the second is the one that matters for execution. Strings belong next to the code
that renders them, so a panel stays self-contained. And **one 900-key `en.json` is a merge conflict
on every parallel edit** — with several agents or several days of work, a single file is a queue.
Namespaces also make an unqualified key impossible to place wrongly: `ui.*` is shared, so anything
under it must not mention a feature.
### D7. Plurals and formatting go through `Intl`
A plural value becomes an object; a string stays a string:
```jsonc
"panel.files.selected": { "one": "{{count}} file selected", "other": "{{count}} files selected" }
```
`t()` picks the branch with `new Intl.PluralRules(locale).select(count)` when `count` is passed.
Categories are per-locale — English and Portuguese use `one`/`other`, Arabic uses six — so the shape
must be an open record, never a two-field type.
A `useFormat()` hook returns `date`, `time`, `dateTime`, `number` and `relative`, each bound to the
active locale. **The ~23 `toLocale*` call sites move to it.** Left alone they follow the browser's
locale and quietly contradict the rest of the UI.
### D8. A missing key falls back to English and is loud in dev
Never render a dot-path to a person. Resolution walks the chain, ends at `en`, and only if English is
missing too does it render the key — which at that point is a bug the parity test below should have
caught.
In dev, a miss logs once per key with the key and the locale. In production it is silent.
---
## The key naming convention
Three segments, sometimes four. **Name the role, never the text.**
```
<namespace>.<area>.<component>.<element>
app.settings.profile.changePassword.title
panel.files.contextMenu.rename
panel.chat.composer.placeholder
ui.button.cancel
app.errors.MINESWEEPER_NOT_RUNNING
```
- `namespace` — `app` / `panel` / `ui`, per D6.
- `area` — the route or panel it belongs to; matches the directory.
- `component` — the file, lowercased.
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
Rules that keep 1200 keys navigable:
- **Never key by the English words.** `ui.button.save`, not `ui.button.saveText` and never
`ui.button.Save`.
- **Never build a key at runtime.** `t(\`panel.${kind}.title\`)` defeats both the type checker and
the extractor. Use an explicit map from the dynamic value to literal keys.
- **`ui.*` may not name a feature.** If a shared component needs feature words, the caller passes
them as a prop.
- **One key per meaning, not per occurrence.** "Cancel" in twelve dialogs is `ui.button.cancel` once.
But a word that is the same in English and different elsewhere — "Post" the noun and the verb —
gets two keys. When unsure, split: merging later is safe, splitting later is not.
---
## What must never be translated
Getting this wrong is worse than missing a string, because it corrupts data or breaks a contract.
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
- **Identifiers that cross a boundary** — route paths, permission keys (`registry.ts`), error *codes*
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
names, database values.
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
reading a terminal at 4am, and they should stay greppable in one language.
- **"Officer"**, and product names generally.
---
## Execution
Four phases. Each is a commit that leaves the tree green — the app must run in English at every point,
because a half-converted UI is unreviewable.
### Phase 0 — infrastructure, no visible change
Everything in "What it cannot do yet". Nothing in this phase touches a component.
1. `resolve.ts`: fallback chain (D2), plural selection (D7), dev-mode miss warning (D8).
2. `I18nProvider`: accept a merged map; expose `useFormat()`.
3. `I18nBridge`: deep-merge the three catalogs (D6); set `dir` on `<html>` (D3).
4. `Languages.tsx`: regional tags, and **filter the picker to locales that actually exist**.
5. Settings migration: widen stored bare codes on read.
6. Scaffold `locales/` in `officerdev` and `components`, each with its typed hook.
7. Land the tooling below.
Phase 0 is the whole plan's risk. It is also small, and it is the only part where a wrong decision is
expensive to reverse — review it before phase 1 starts.
### Phase 1 — shared components (91 files, ~47 strings)
Smallest, highest reuse, and it establishes `ui.*` before anything depends on it. Do this first so
phases 2 and 3 can reach for `ui.button.cancel` instead of inventing it twice.
### Phase 2 — the shell (132 files, ~556 strings)
`officer-web`: settings, dock, header, screens. Highest-traffic text and the part a reviewer will
look at first. Convert per directory, one commit per area.
### Phase 3 — the panels (213 files, ~567 strings)
`officerdev` apps. The largest surface and the most parallelizable, because panels barely share
strings. **One panel per commit** — this is where per-workspace catalogs earn their keep, but two
agents in the same panel will still conflict.
### Then: `pt-PT`
Only after English is fully keyed. Translating against a moving catalog is wasted work.
---
## Tooling and guardrails
Without these, 1200 keys rot. All three are small and belong in phase 0.
**`scripts/i18n/scan-hardcoded.ts`** — already written and committed. Reports every candidate
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
meter: the count goes down as work lands.
**A key-parity test.** For each shipped locale, every key that `en` has must resolve through the
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
one guard that stops a locale silently degrading into English.
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
it globally on day one would just be red forever.
**Also worth having:** an unused-key report. Keys outlive the components that used them, and nothing
else will ever notice.
---
## Verification
- `bunx tsgo` clean. A wrong key is a type error, so this is the primary check.
**Note for this machine:** `earlyoom` (`-r 3600`) SIGTERMs `tsgo` under memory pressure — it dies
around 15s with `Command failed`. That is not a type error. Free memory or scope the check.
- `bun test` — parity test green for every shipped locale.
- `bun scripts/i18n/scan-hardcoded.ts` — count strictly decreasing per phase.
- By hand: switch the locale to `pt-PT` and walk the converted area. Untranslated strings should read
as **English, never as dot-paths**.
- Check a plural at 0, 1 and 2, and a date, in both locales. These are the two things that look fine
in English and wrong everywhere else.
---
## Open questions
1. **Routes.** Deferred deliberately. Nothing here depends on it, and localized URLs bring
canonicalization and redirect questions that are not worth answering yet.
2. **Who translates?** Machine translation for `pt-PT` and `pt-BR` reviewed by the owner is the
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
manifest. Their strings are not counted above.
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
+20 -1
View File
@@ -29,13 +29,32 @@ Seven, all running.
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
reachable from inside only.
## Deliberately skipped
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
and it must not be reported as one.
| Host | Since | Why |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `edge-pertento-bridge` | 2026-08-23 | Down, and staying down for some days. Owner's call, not a fault. |
Its row, kept so restoring it is one move:
```
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
```
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
is what this section exists to prevent: a host that is known to be down should cost a line in a report,
not a retry budget.
### Names that disagree with themselves
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.23.01",
"version": "2026.08.23.02",
"private": true,
"type": "module",
"workspaces": [
+14 -1
View File
@@ -136,6 +136,11 @@ That document holds every machine, its tailnet name, **its login user**, its che
platform runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
platform as out of scope.
It also has a **Deliberately skipped** section. A host listed there is not a target: name it as skipped
in the report and spend nothing on it. On 2026-08-23 a host that was known to be down cost four attempts
and a minute apiece before being reported unreachable — correct behaviour against a list that had not been
told. Read that section before you try to reach anything.
**The login is not always `pastilhas`.** `deadpool-rezio-bridge` runs as `br`, permanently, and more will.
Take the user from the table — an orchestrator that hard-codes one fails on that host silently, because
`Permission denied (publickey)` looks like a broken key rather than a wrong username.
@@ -163,7 +168,15 @@ assemble at the end from memory is a report that will be wrong about the host th
`--porcelain` counts untracked files, and on 2026-08-21 that skipped a host over one stray script a
fast-forward could never have touched. Untracked files get a note in the report, not a skip. Never
discard someone's work to ship a release.
2. `git pull --ff-only`. A non-fast-forward means that checkout has diverged: skip and report.
2. **`git fetch origin --tags` then `git merge --ff-only <tag>`** — the TAG you are deploying, not
`master`. `git pull --ff-only` follows the branch, so a commit pushed while you are rolling out lands
on some hosts and not others, and "deployed the tag" stops being true of any of them. That happened on
2026-08-23: every host came up one commit past the tag because a docs commit landed mid-rollout. It was
harmless and it need not have been.
A non-fast-forward means that checkout has diverged: skip and report. A tag that will not fast-forward
because the host is AHEAD is the same answer — report it, do not reset.
3. **Update the plugins.** `bun run plugins:update`. Sequential by design, one plugin failing does not
stop the rest, and it exits non-zero if any did — so a failure is something you report rather than
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* Counts candidate hardcoded user-facing strings in the web UI.
*
* Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area
* reaches zero it becomes the guard that stops the debt coming back — see ZEROED below.
*
* It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries
* inside JSX, strings passed down as props, or anything a plugin renders. A falling number means
* progress; a zero means "nothing this scanner can see", not "nothing left".
*
* bun scripts/i18n/scan-hardcoded.ts # summary by area
* bun scripts/i18n/scan-hardcoded.ts --files # worst files first
* bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves
* bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const AREAS = [
{ label: 'officer-web (shell)', root: 'src/apps/officer-web' },
{ label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' },
{ label: 'shared components', root: 'src/workspaces/components' },
] as const;
/**
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
* zero — enabling every area on day one would just be red for weeks and get ignored.
*/
const ZEROED: string[] = [];
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
const PATTERNS: { kind: string; rx: RegExp }[] = [
// >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends
{
kind: 'attributes',
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
},
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
{
kind: 'object labels',
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
},
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
];
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
function isHuman(value: string | undefined): value is string {
if (!value) return false;
const s = value.trim();
if (s.length < 3) return false;
if (!/[A-Za-z]{3}/.test(s)) return false;
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.test(s)) return false;
return true;
}
function* walk(dir: string): Generator<string> {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const path = join(dir, entry);
if (SKIP.test(path)) continue;
let isDir = false;
try {
isDir = statSync(path).isDirectory();
} catch {
continue;
}
if (isDir) yield* walk(path);
else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path;
}
}
type Hit = { kind: string; value: string; file: string };
function scan(root: string): { files: number; hits: Hit[] } {
const hits: Hit[] = [];
let files = 0;
for (const file of walk(root)) {
files++;
let source: string;
try {
source = readFileSync(file, 'utf8');
} catch {
continue;
}
for (const { kind, rx } of PATTERNS) {
rx.lastIndex = 0;
for (const match of source.matchAll(rx)) {
const value = match.slice(1).find((group) => group !== undefined);
if (isHuman(value)) hits.push({ kind, value: value.trim(), file });
}
}
}
return { files, hits };
}
const args = new Set(process.argv.slice(2));
const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined;
const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n);
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
}
const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
console.log();
console.log(`occurrences ${all.length}`);
console.log(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`);
if (args.has('--files')) {
console.log('\nworst files:');
const perFile = new Map<string, number>();
for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1);
for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) {
console.log(` ${pad(n, 4)} ${file}`);
}
}
if (listPrefix) {
console.log(`\nstrings in files matching "${listPrefix}":`);
for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`);
}
if (args.has('--check')) {
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:');
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1);
}
console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`);
}
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -155,6 +155,7 @@ import {
Store,
Puzzle,
Radar,
Send,
} from 'lucide-react';
/**
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
export * from './SendOff';
+1
View File
@@ -40,6 +40,7 @@ export * from './ai-provider-usage';
export * from './anthropic-accounts';
export * from './theme-favorites';
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './sendoff';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';
+1
View File
@@ -37,6 +37,7 @@ export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
export * from './sendoff/schema'; // sendoff_devices, sendoff_senders
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
@@ -0,0 +1,16 @@
export { sendoffDevices, sendoffSenders } from './schema';
export {
upsertSendoffDevice,
listSendoffDevices,
renameSendoffDevice,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
allowSender,
revokeSender,
maySendTo,
accountsByIds,
type SendoffDevice,
type SendoffPerson,
} from './queries';
@@ -0,0 +1,122 @@
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '../db';
import { users } from '../auth/schema';
import { sendoffDevices, sendoffSenders } from './schema';
export type SendoffDevice = { id: string; name: string; platform: string | null; lastSeenAt: Date };
/**
* Register or refresh a device. Called on every app start, so it must be idempotent on the client's own id.
*
* `lastSeenAt` is refreshed on conflict and the name is overwritten, because the client is the authority on
* what it calls itself — renaming on the device should show up in the picker without a second endpoint.
*/
export async function upsertSendoffDevice(params: {
id: string;
userId: number;
name: string;
platform?: string | null;
}): Promise<void> {
await db
.insert(sendoffDevices)
.values({
id: params.id,
userId: params.userId,
name: params.name,
platform: params.platform ?? null,
})
.onConflictDoUpdate({
target: sendoffDevices.id,
set: { name: params.name, platform: params.platform ?? null, lastSeenAt: new Date() },
});
}
export async function listSendoffDevices(userId: number): Promise<SendoffDevice[]> {
const rows = await db
.select({
id: sendoffDevices.id,
name: sendoffDevices.name,
platform: sendoffDevices.platform,
lastSeenAt: sendoffDevices.lastSeenAt,
})
.from(sendoffDevices)
.where(eq(sendoffDevices.userId, userId));
return rows;
}
/** Scoped to the owner of the row: a device id is a client-chosen uuid and must not be a way to reach another account's. */
export async function renameSendoffDevice(userId: number, id: string, name: string): Promise<boolean> {
const rows = await db
.update(sendoffDevices)
.set({ name })
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export async function deleteSendoffDevice(userId: number, id: string): Promise<boolean> {
const rows = await db
.delete(sendoffDevices)
.where(and(eq(sendoffDevices.id, id), eq(sendoffDevices.userId, userId)))
.returning({ id: sendoffDevices.id });
return rows.length > 0;
}
export type SendoffPerson = { id: number; email: string; name: string | null };
/** The accounts I have allowed to send to me. */
export async function listAllowedSenders(recipientUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.senderUserId))
.where(eq(sendoffSenders.recipientUserId, recipientUserId));
return rows;
}
/**
* The accounts I may send to — the inverse question, and the one the picker asks.
*
* Absence denies, so this returns only recipients who have named me. A sender never learns that an account
* exists and refused them; it simply is not in the list.
*/
export async function listMyRecipients(senderUserId: number): Promise<SendoffPerson[]> {
const rows = await db
.select({ id: users.id, email: users.email, name: users.name })
.from(sendoffSenders)
.innerJoin(users, eq(users.id, sendoffSenders.recipientUserId))
.where(eq(sendoffSenders.senderUserId, senderUserId));
return rows;
}
/** Every account except me — what the owner's picker shows, since the owner may send to anyone. */
export async function listOtherAccounts(exceptUserId: number): Promise<SendoffPerson[]> {
const rows = await db.select({ id: users.id, email: users.email, name: users.name }).from(users);
return rows.filter((r) => r.id !== exceptUserId);
}
export async function allowSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db.insert(sendoffSenders).values({ recipientUserId, senderUserId }).onConflictDoNothing();
}
export async function revokeSender(recipientUserId: number, senderUserId: number): Promise<void> {
await db
.delete(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)));
}
/** May this sender write into this recipient's tree? The owner check is the CALLER's job — see the schema. */
export async function maySendTo(senderUserId: number, recipientUserId: number): Promise<boolean> {
const rows = await db
.select({ id: sendoffSenders.id })
.from(sendoffSenders)
.where(and(eq(sendoffSenders.recipientUserId, recipientUserId), eq(sendoffSenders.senderUserId, senderUserId)))
.limit(1);
return rows.length > 0;
}
/** Resolve accounts by id, for naming a recipient in a response without a second round trip. */
export async function accountsByIds(ids: number[]): Promise<SendoffPerson[]> {
if (!ids.length) return [];
return db.select({ id: users.id, email: users.email, name: users.name }).from(users).where(inArray(users.id, ids));
}
@@ -0,0 +1,55 @@
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
* A named machine, so "send it to my laptop" has something to pick from.
*
* Separate from `push_devices` deliberately, and the two are not the same population. A push device is a
* token: iOS or Android only, no human name, and it exists to be pushed AT. This is a name a person chose,
* for a machine that may never receive a push at all — a desktop, a browser, a box with no app on it. The
* name's only job is to become a folder.
*
* The id is the client's own uuid rather than a serial, because registration happens on every app start and
* has to be idempotent without the client remembering a number we gave it.
*/
export const sendoffDevices = pgTable('sendoff_devices', {
id: text('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls it. Becomes a directory name, so it is sanitised before it touches the disk. */
name: text('name').notNull(),
platform: text('platform'),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* Who may send files to whom — held by the RECIPIENT, one row per sender they have allowed.
*
* This is the whole cross-user permission model and it is deliberately not a role grant. A grant answers
* "may this role send files", which is the wrong question: the person who should decide whether Fedra can
* put a file in your home directory is you, not whoever administers roles. So consent is data, it is
* per-pair, and absence denies — a sender who has not been allowed does not see the recipient as a target
* at all, rather than seeing one and being refused.
*
* The owner is exempt as a SENDER and only as a sender: they bypass every other check on this platform and
* there is no version of this where they cannot reach a home directory they already own. Receiving still
* needs their say-so, so nobody lands files in the owner's tree uninvited.
*/
export const sendoffSenders = pgTable(
'sendoff_senders',
{
id: serial('id').primaryKey(),
/** The account granting permission — the one whose tree gets written to. */
recipientUserId: integer('recipient_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** The account allowed to send to them. */
senderUserId: integer('sender_user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
(table) => [uniqueIndex('uq_sendoff_senders').on(table.recipientUserId, table.senderUserId)],
);
+72
View File
@@ -0,0 +1,72 @@
import { join } from 'node:path';
import { getHomeDir, getOwnerHomeDir } from '@@/data-path';
/** `Documents/SendOff` under whichever home this account actually browses. */
export const SENDOFF_SUBPATH = join('Documents', 'SendOff');
/**
* The SendOff directory for an account, in the home its own file browser shows.
*
* The two homes are not interchangeable and picking the wrong one makes a delivery invisible: the owner
* browses their real login home (`/home/pastilhas`), while another account browses the managed home under
* `DATA_PATH/<email>/home`. `getOwnerHomeDir` ignores the email it is passed and always answers the owner,
* so it can only be used once the role has been checked — which is why this takes the role rather than
* working it out from the email.
*/
export function sendoffDirFor(account: { email: string; isOwner: boolean }): string {
const home = account.isOwner ? getOwnerHomeDir(account.email) : getHomeDir(account.email);
return join(home, SENDOFF_SUBPATH);
}
/**
* A folder name safe to create, from a name a person or a device chose.
*
* Anything that is not a letter, digit, dash, underscore or dot becomes a dash, and leading dots go — so a
* name cannot produce `..`, a hidden directory, or a path with a separator in it. Empty after that is
* refused by the caller rather than silently becoming a directory called `-`.
*/
export function safeSegment(name: string): string {
return name
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
/**
* A filename safe to write, preserving the extension.
*
* Same rules as a segment, with the basename taken first: a browser will happily send `../../etc/passwd`
* as a filename and the multipart spec does not forbid it.
*/
export function safeFilename(name: string): string {
const base = name.split(/[/\\]/).pop() ?? '';
const cleaned = base
.trim()
.replace(/[^A-Za-z0-9._-]+/g, '-')
.replace(/^\.+/, '')
.slice(0, 200);
return cleaned || 'file';
}
/**
* The name an account appears as, and therefore the name of the folder its files land in.
*
* One definition, used by the writer AND by `/targets`, because the two must agree: a picker that says
* "Fedra" while the writer creates `fedra.almeida` has told somebody the wrong place to look. Mobile asked
* for this to be served rather than derived, which is right — three clients reimplementing a fallback is
* how three clients disagree.
*
* `users.name` is nullable, so the fallback chain ends at the account id, which is always there.
*/
export function displayNameFor(account: { id: number; name: string | null; email: string }): string {
const chosen = account.name?.trim();
if (chosen) return chosen;
const local = account.email.split('@')[0]?.trim();
return local || `user-${account.id}`;
}
/** What that name becomes on disk — the same value, through the segment sanitiser. */
export const folderNameFor = (account: { id: number; name: string | null; email: string }): string =>
safeSegment(displayNameFor(account)) || `user-${account.id}`;
+248
View File
@@ -0,0 +1,248 @@
import { mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { extname, join } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import {
accountsByIds,
allowSender,
deleteSendoffDevice,
listAllowedSenders,
listMyRecipients,
listOtherAccounts,
listSendoffDevices,
maySendTo,
renameSendoffDevice,
revokeSender,
upsertSendoffDevice,
} from 'officerdb';
import { getNotifyServerUrl } from '../notify/router';
import { displayNameFor, folderNameFor, safeFilename, safeSegment, sendoffDirFor } from './paths';
/*
SendOff — files land in a real directory in the recipient's own tree.
There is no staging area, no transfer record and nothing to expire. Every account already has a home, a
file browser over it and a Files app on the phone, so a delivered file is an ordinary file: visible in
every UI that exists, and managed like anything else its owner owns.
Two things are worth knowing before changing anything here.
**This is the only sanctioned door into another account's filesystem.** `/file-browser` and `/upload`
write exclusively into the caller's own tree and that must stay true. So this route writes ONLY under
`Documents/SendOff/<sender>/`, and `<sender>` comes from the token — never from the payload — so a folder
name cannot lie about who put a file there.
**Consent belongs to the recipient.** A role grant would answer "may this role send files", which is the
wrong question; the person who decides whether somebody may write into your home is you. So it is a row,
per pair, held by the recipient, and absence denies. A sender who has not been allowed does not see the
recipient in their picker at all rather than seeing one and being refused — which also means this endpoint
never confirms that an account exists.
*/
export const sendoffRouter = createRouter();
const MAX_FILE_SIZE = 512 * 1024 * 1024; // 512 MB — this is a file transfer, not an avatar upload
const isOwnerRole = (role: string | null | undefined): boolean => role === 'Super Admin';
/**
* A path that does not exist yet, suffixing ` (1)`, ` (2)`, … before the extension.
*
* Never overwrite. A second send of the same name is a second file: the sender cannot see the recipient's
* directory, so they have no way to know they are about to destroy something, and silently replacing a file
* somebody was sent is the one outcome with no recovery.
*/
function freePath(dir: string, filename: string): string {
const first = join(dir, filename);
if (!existsSync(first)) return first;
const ext = extname(filename);
const stem = filename.slice(0, filename.length - ext.length);
for (let n = 1; n < 1000; n++) {
const candidate = join(dir, `${stem} (${n})${ext}`);
if (!existsSync(candidate)) return candidate;
}
return join(dir, `${stem} (${Date.now()})${ext}`);
}
/*
The push, and why it carries what it does.
`id` is the FOLDER NAME — a username or a machine name — and `n` is a file count. Both are interpolated by
the catalogue on the device ("3 file(s) from andre") and both are what the route needs to open the right
directory. No filename and no path travels: the folder name plus the count is what the owner accepted as
the line, and it is enough for the tap to land somewhere useful.
Never fatal. A delivery that succeeded must not be reported as a failure because a notification did not
go out — the file is already in their tree, and the file browser shows it either way.
*/
async function pushLanded(recipientUserId: number, folder: string, count: number): Promise<void> {
try {
const base = getNotifyServerUrl();
if (!base) return;
await fetch(`${base}/_officer/notify`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-Officer-User': String(recipientUserId) },
body: JSON.stringify({ code: 'officer/core:sendoff.landed', userId: recipientUserId, id: folder, n: count }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
console.error('[sendoff] could not push:', err instanceof Error ? err.message : err);
}
}
/**
* POST /sendoff/send — multipart: any number of `file` parts, plus `toUser` or `machine`.
*
* `toUser` is an account id and writes into THEIR tree under `SendOff/<my-username>/`. `machine` is a name
* of my own and writes into MY tree under `SendOff/<machine>/`. Exactly one of the two.
*/
sendoffRouter.post('/send', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as Record<string, unknown>;
// `parseBody({ all: true })` gives an array when a part repeats and a single value when it does not, so
// both shapes have to be accepted — a one-file send is the common case and must not need a special part.
const raw = body.file;
const files = (Array.isArray(raw) ? raw : [raw]).filter((f): f is File => f instanceof File);
if (!files.length) throw errors.BAD_REQUEST('at least one file is required');
const tooBig = files.find((f) => f.size > MAX_FILE_SIZE);
if (tooBig) throw errors.BAD_REQUEST(`${tooBig.name} exceeds the ${MAX_FILE_SIZE / 1024 / 1024} MB limit`);
const toUser = body.toUser === undefined || body.toUser === '' ? null : Number(body.toUser);
const machine = typeof body.machine === 'string' && body.machine.trim() ? body.machine.trim() : null;
if ((toUser === null) === (machine === null)) {
throw errors.BAD_REQUEST('send to exactly one of toUser or machine');
}
let targetUserId: number;
let targetAccount: { email: string; isOwner: boolean };
let folder: string;
if (machine) {
// My own machine, my own tree. The name is mine to choose and is not checked against the device
// registry: the registry exists to POPULATE a picker, not to be the authority on what I may call a
// folder in my own home.
targetUserId = me.id;
targetAccount = { email: me.email, isOwner: isOwnerRole(me.role) };
folder = safeSegment(machine);
} else {
if (toUser === me.id) throw errors.BAD_REQUEST('use machine to send to yourself');
if (!Number.isInteger(toUser) || toUser === null || toUser <= 0)
throw errors.BAD_REQUEST('toUser must be an account id');
const [recipient] = await accountsByIds([toUser]);
// 404, never 403, and the same answer for "no such account" as for "did not allow you". A sender must
// not be able to enumerate accounts, nor learn that one refused them.
if (!recipient) throw errors.NOT_FOUND('no such recipient');
const allowed = isOwnerRole(me.role) || (await maySendTo(me.id, toUser));
if (!allowed) throw errors.NOT_FOUND('no such recipient');
const [full] = await accountsByIds([toUser]);
targetUserId = toUser;
targetAccount = { email: recipient.email, isOwner: full?.id === 1 };
// From the TOKEN. This is the whole reason the folder name can be trusted.
folder = folderNameFor({ id: me.id, name: me.name ?? null, email: me.email });
}
if (!folder) throw errors.BAD_REQUEST('that name has no usable characters');
const dir = join(sendoffDirFor(targetAccount), folder);
await mkdir(dir, { recursive: true });
const written: { name: string; size: number }[] = [];
for (const file of files) {
const path = freePath(dir, safeFilename(file.name));
await Bun.write(path, await file.arrayBuffer());
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
}
void pushLanded(targetUserId, folder, written.length);
return ctx.json({ path: join('Documents', 'SendOff', folder), files: written });
});
/**
* GET /sendoff/targets — everything the send form needs: my machines, and who I may send to.
*
* The owner sees every account because they may send to anyone; everybody else sees only the accounts that
* have named them. One request rather than two, because a picker with half its options is not a picker.
*/
sendoffRouter.get('/targets', async (ctx) => {
const me = ctx.get('user');
const [devices, people] = await Promise.all([
listSendoffDevices(me.id),
isOwnerRole(me.role) ? listOtherAccounts(me.id) : listMyRecipients(me.id),
]);
/*
`displayName` is served rather than derived, at mobile's request and for the right reason: the writer
already computes this exact string to name a folder, and a picker that computes its own would sooner or
later disagree with it — which means telling somebody the wrong place to look.
`myFolder` answers the question a picker actually wants: files I send land in the RECIPIENT's tree under
MY name, so it is one value for the whole list, not a field on each row.
*/
return ctx.json({
devices,
people: people.map((p) => ({ ...p, displayName: displayNameFor(p) })),
myFolder: folderNameFor({ id: me.id, name: me.name ?? null, email: me.email }),
});
});
/** GET /sendoff/senders — who I have allowed to send to me. */
sendoffRouter.get('/senders', async (ctx) => {
const me = ctx.get('user');
const [allowed, everyone] = await Promise.all([listAllowedSenders(me.id), listOtherAccounts(me.id)]);
const allowedIds = new Set(allowed.map((a) => a.id));
// Both lists, so the UI can render a set of toggles rather than an "add" flow over an account picker.
return ctx.json({ allowed, candidates: everyone.map((p) => ({ ...p, allowed: allowedIds.has(p.id) })) });
});
sendoffRouter.post('/senders', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { userId?: number };
const id = Number(body?.userId);
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
if (id === me.id) throw errors.BAD_REQUEST('you can always send to yourself');
const [account] = await accountsByIds([id]);
if (!account) throw errors.NOT_FOUND('no such account');
await allowSender(me.id, id);
return ctx.json({ ok: true });
});
sendoffRouter.delete('/senders/:userId', async (ctx) => {
const me = ctx.get('user');
const id = Number(ctx.req.param('userId'));
if (!Number.isInteger(id) || id <= 0) throw errors.BAD_REQUEST('userId is required');
// Revoking stops future sends. Files already delivered are the recipient's own files now, and deleting
// somebody's files as a side effect of a permission change would be its own kind of surprise.
await revokeSender(me.id, id);
return ctx.json({ ok: true });
});
/** POST /sendoff/devices — register or refresh. Idempotent on the client's own uuid; called every launch. */
sendoffRouter.post('/devices', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { id?: string; name?: string; platform?: string };
const id = typeof body?.id === 'string' ? body.id.trim() : '';
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!id || !name) throw errors.BAD_REQUEST('id and name are required');
if (!safeSegment(name)) throw errors.BAD_REQUEST('that name has no usable characters');
await upsertSendoffDevice({ id, userId: me.id, name, platform: body?.platform ?? null });
return ctx.json({ ok: true });
});
sendoffRouter.get('/devices', async (ctx) => ctx.json({ devices: await listSendoffDevices(ctx.get('user').id) }));
sendoffRouter.patch('/devices/:id', async (ctx) => {
const me = ctx.get('user');
const body = ctx.get('body') as { name?: string };
const name = typeof body?.name === 'string' ? body.name.trim() : '';
if (!name || !safeSegment(name)) throw errors.BAD_REQUEST('name is required');
if (!(await renameSendoffDevice(me.id, ctx.req.param('id'), name))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
sendoffRouter.delete('/devices/:id', async (ctx) => {
const me = ctx.get('user');
if (!(await deleteSendoffDevice(me.id, ctx.req.param('id')))) throw errors.NOT_FOUND('no such device');
return ctx.json({ ok: true });
});
+2
View File
@@ -27,6 +27,7 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { sendoffRouter } from './api/sendoff/router';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -141,6 +142,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
['/sendoff', sendoffRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
+16
View File
@@ -99,6 +99,22 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
route: 'officer://jobs/{id}',
thread: 'officer/core:job',
},
/*
SendOff. `id` is the FOLDER — a username, or one of your own machine names — and `n` is how many
files landed, so the device renders "3 file(s) from andre" and the route opens that directory.
A folder name and a count travel, and nothing else: no filename, no path, no size. That is a
deliberate relaxation of "content does not travel" rather than an oversight — the owner's call, on
the grounds that the file itself never leaves the tailnet, and a notification nobody can act on is
not worth sending. A filename would be content; whose folder it is, is addressing.
*/
'officer/core:sendoff.landed': {
title: 'SendOff',
body: '{n} file(s) from {id}',
target: 'mobile',
route: 'officer://files?path=Documents/SendOff/{id}',
thread: 'sendoff:{id}',
},
'officer/core:job-failed': {
title: 'Job failed',
target: 'mobile',
+21
View File
@@ -347,6 +347,27 @@ const CORE_REGISTRY: Permission[] = [
ws: ['minesweeper'],
routes: ['/minesweeper'],
},
/*
SendOff is `app`, not `confined`, and the distinction is the point.
It writes into a home directory, which normally means `confined` — but it writes exactly one shape of
path, `Documents/SendOff/<sender>/`, chosen by the server from the caller's token. There is no path in
the request to contain, nothing to traverse out of, and no way to read anything back. So the Linux-user
requirement that `confined` exists to enforce buys nothing here, and requiring it would mean an account
with no OS user could not be sent a file — which is most of the point of the feature.
WHO may send to whom is not decided here. It is a row per pair held by the recipient, because the
person who should decide whether somebody may put a file in your home is you, not whoever administers
roles. This grant only says the feature is reachable at all.
*/
{
key: 'sendoff',
label: 'SendOff',
description: 'Send files to your own machines, and to accounts that have allowed you',
kind: 'app',
api: ['/sendoff'],
routes: ['/sendoff'],
},
{
key: 'items',
label: 'Permission authoring',
@@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query';
@@ -30,6 +31,7 @@ export const apps = [
...monitorMetas,
...qrTransferMetas,
...minesweeperMetas,
...sendOffMetas,
...davMetas,
...commandHqMetas,
...appStoreMetas,
@@ -0,0 +1,226 @@
import { useRef, useState } from 'react';
import { Laptop, Send, Upload, User, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
const kb = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; name: string };
/**
* SendOff — pick files, pick where, send.
*
* Deliberately has no "received" view. Files land in `Documents/SendOff/` in the recipient's own tree, so
* the file browser is already the receiving UI and a second one here would be a worse copy of it that
* needed keeping in sync.
*/
export const SendOffPanel = () => {
const { devices, people, myFolder, isLoading } = useSendOffTargets();
const send = useSendOff();
const [files, setFiles] = useState<File[]>([]);
const [target, setTarget] = useState<Target | null>(null);
const picker = useRef<HTMLInputElement>(null);
const total = files.reduce((n, f) => n + f.size, 0);
const ready = files.length > 0 && target !== null;
const doSend = async () => {
if (!target) return;
await send
.mutateAsync({ files, target: target.kind === 'machine' ? { machine: target.name } : { toUser: target.id } })
// Clear only the files. The target stays selected because sending several batches to the same place
// is the common second action, and re-picking it every time is the kind of small tax that stops
// somebody using a thing.
.then(() => setFiles([]))
.catch(() => {});
};
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
<section>
<h3 className="text-sm font-semibold text-foreground">Files</h3>
<input
ref={picker}
type="file"
multiple
className="hidden"
onChange={(ev) => {
setFiles((prev) => [...prev, ...Array.from(ev.target.files ?? [])]);
// Reset the input, or picking the same file twice in a row does nothing: `change` does not
// fire when the value is unchanged.
ev.target.value = '';
}}
/>
<Button variant="outline" className="mt-2 w-full justify-start" onClick={() => picker.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
{files.length ? 'Add more files…' : 'Choose files…'}
</Button>
{files.length > 0 && (
<ul className="mt-2 flex flex-col gap-1">
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1 text-sm"
>
<span className="truncate text-foreground">{f.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
<button
type="button"
aria-label={`Remove ${f.name}`}
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
<li className="px-2 pt-1 text-xs text-muted-foreground">
{files.length} file{files.length === 1 ? '' : 's'} · {kb(total)}
</li>
</ul>
)}
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">Where</h3>
{isLoading ? (
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
) : (
<div className="mt-2 flex flex-col gap-3">
<TargetGroup
icon={Laptop}
heading="My machines"
empty="No machines registered yet — an Officer app registers itself on start."
options={devices.map((d) => ({
key: `m:${d.id}`,
name: d.name,
target: { kind: 'machine' as const, name: d.name },
}))}
selected={target}
onSelect={setTarget}
/>
<TargetGroup
icon={User}
heading="People"
empty="Nobody has allowed you to send to them yet."
// `displayName` from the server, never computed here: it is the folder the writer will
// create, and a picker that guessed it would sooner or later name the wrong directory.
options={people.map((p) => ({
key: `u:${p.id}`,
name: p.displayName,
target: { kind: 'user' as const, id: p.id, name: p.displayName },
}))}
selected={target}
onSelect={setTarget}
/>
</div>
)}
</section>
<section className="flex flex-col gap-2">
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
<Send className="mr-2 h-4 w-4" />
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
</Button>
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
revoked you are different problems with different answers. */}
{target?.kind === 'user' && myFolder && (
<p className="text-xs text-muted-foreground">
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
</p>
)}
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
{send.data && (
<p className="text-sm text-muted-foreground">
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
{send.data.files.map((f) => f.name).join(', ')}
</p>
)}
</section>
<WhoMaySend />
</div>
</div>
);
};
type TargetGroupProps = {
icon: typeof Laptop;
heading: string;
empty: string;
options: { key: string; name: string; target: Target }[];
selected: Target | null;
onSelect: (t: Target) => void;
};
const sameTarget = (a: Target | null, b: Target): boolean =>
a !== null &&
a.kind === b.kind &&
(b.kind === 'machine' ? a.kind === 'machine' && a.name === b.name : a.kind === 'user' && a.id === b.id);
const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }: TargetGroupProps) => (
<div>
<p className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{heading}
</p>
{options.length === 0 ? (
<p className="mt-1 text-sm text-muted-foreground">{empty}</p>
) : (
<div className="mt-1 flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o.key}
size="sm"
variant={sameTarget(selected, o.target) ? 'default' : 'outline'}
onClick={() => onSelect(o.target)}
>
{o.name}
</Button>
))}
</div>
)}
</div>
);
/**
* The consent list, on the same screen as sending.
*
* It belongs here rather than in settings because the two questions arrive together: the first time
* somebody opens SendOff and finds nobody to send to, the reason is that consent runs the other way, and
* the answer has to be visible in the same place the question was asked.
*/
const WhoMaySend = () => {
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
if (isLoading || candidates.length === 0) return null;
return (
<section className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
<p className="mt-1 text-xs text-muted-foreground">
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
Turning someone off stops future sends and leaves what they already sent alone.
</p>
<div className="mt-2 flex flex-col gap-1">
{candidates.map((p) => (
<label
key={p.id}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
>
<span className="truncate text-foreground">{label(p)}</span>
<input
type="checkbox"
checked={p.allowed}
disabled={isSaving}
onChange={(ev) => void setAllowed(p.id, ev.target.checked).catch(() => {})}
/>
</label>
))}
</div>
</section>
);
};
@@ -0,0 +1,10 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Send } from 'lucide-react';
import { SendOffPanel } from './SendOffPanel';
export { SendOffPanel } from './SendOffPanel';
export { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'sendoff', name: 'SendOff', icon: Send, component: SendOffPanel },
];
@@ -0,0 +1,85 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
export type SendOffPerson = { id: number; email: string; name: string | null };
/** As served by `/targets` — `displayName` is the folder name, computed once on the server. */
export type SendOffTargetPerson = SendOffPerson & { displayName: string };
type Targets = { devices: SendOffDevice[]; people: SendOffTargetPerson[]; myFolder: string };
type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
const TARGETS = ['sendoff', 'targets'];
const SENDERS = ['sendoff', 'senders'];
/** Where a file can go: my own named machines, and the accounts that have allowed me. */
export function useSendOffTargets() {
const client = useClient();
const { data, isLoading, error } = useQuery<Targets>({
queryKey: TARGETS,
queryFn: () => client.get<Targets>('/sendoff/targets'),
// A machine registered from a phone a moment ago should appear without a reload, and this is a
// two-row query — the panel is the only reader and it is open for seconds at a time.
staleTime: 0,
});
return { devices: data?.devices ?? [], people: data?.people ?? [], myFolder: data?.myFolder ?? '', isLoading, error };
}
/** Who I have allowed to send to me — the consent list, which only I can change. */
export function useSendOffSenders() {
const client = useClient();
const qc = useQueryClient();
const { data, isLoading } = useQuery<Senders>({
queryKey: SENDERS,
queryFn: () => client.get<Senders>('/sendoff/senders'),
staleTime: 0,
});
const allow = useMutation({
mutationFn: (userId: number) => client.post('/sendoff/senders', { userId }),
// Both: granting changes who may send to ME, and on somebody else's screen it changes who they may
// send TO. Only this browser's cache is reachable from here, so it invalidates its own of each.
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
const revoke = useMutation({
mutationFn: (userId: number) => client.delete(`/sendoff/senders/${userId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
return {
candidates: data?.candidates ?? [],
isLoading,
setAllowed: (userId: number, allowed: boolean) =>
allowed ? allow.mutateAsync(userId) : revoke.mutateAsync(userId),
isSaving: allow.isPending || revoke.isPending,
};
}
export type SendResult = { path: string; files: { name: string; size: number }[] };
/**
* The send itself — multipart, through the ordinary client.
*
* `client.post` already recognises a `FormData` and drops the JSON content-type when it sees one, so the
* browser picks the boundary. That is the only thing that made this look like it needed a raw `fetch`.
*/
export function useSendOff() {
const client = useClient();
const qc = useQueryClient();
return useMutation<SendResult, Error, { files: File[]; target: { machine: string } | { toUser: number } }>({
mutationFn: ({ files, target }) => {
const form = new FormData();
for (const f of files) form.append('file', f);
if ('machine' in target) form.append('machine', target.machine);
else form.append('toUser', String(target.toUser));
return client.post<SendResult>('/sendoff/send', form);
},
onSuccess: () => void qc.invalidateQueries({ queryKey: TARGETS }),
});
}
+8
View File
@@ -0,0 +1,8 @@
---
theme: sendoff
heading: 'SendOff'
kind: theme
---
Sending files to your own machines, and to accounts that have allowed you. Landed 2026-08-23 as a folder
convention plus one endpoint — `docs/sendoff.md` is the design, `src/servers/api/sendoff/` the code.
+30
View File
@@ -0,0 +1,30 @@
---
id: sendoff/per-user-size-limit
title: 'The SendOff file size limit is one constant for everybody'
theme: sendoff
status: open
created: 2026-08-23
date_source: stated
order: 1
---
**`MAX_FILE_SIZE` in `src/servers/api/sendoff/router.ts` is a hard-coded 512 MB and applies to every
account equally.** The owner asked for it to be configurable per user, from settings, on the day the
feature landed — 512 MB was chosen as "enough for now" rather than as a considered ceiling.
Why it wants to be per-user rather than one server-wide number: the accounts differ in what they are for.
The owner sending themselves a video off a phone and a member being allowed to drop a document are not the
same size of thing, and the honest limit for one is an insult or a hazard for the other. A single number
has to be set for the most generous case, which is exactly the case you would want to bound.
Shape, when it is built:
- An owner-only setting, per account, defaulting to the current 512 MB when unset. Absence means default,
not unlimited — the same posture the permission model already takes.
- Enforced in `POST /sendoff/send` where the constant is read today, so there is one place it can be wrong.
- The refusal has to say the limit it applied. A 400 reading "too big" against an invisible per-account
number is the kind of error somebody retries three times before asking.
Worth deciding at the same time, because it is the same question one layer out: whether a **disk quota**
belongs here too. A size limit bounds one send; nothing currently bounds how much a sender may accumulate
in somebody's tree over a month, and the recipient's only tool is revoking them afterwards.