Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea25d76c8 | ||
|
|
b94a5e90b8 | ||
|
|
510fa3f524 | ||
|
|
b5d9f591ee | ||
|
|
e6f1388aa6 |
@@ -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:
|
- `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
|
`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
|
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
|
- `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,
|
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`.
|
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,
|
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
|
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
|
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`
|
- `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
|
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
|
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -29,13 +29,32 @@ Seven, all running.
|
|||||||
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||||
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
| `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` |
|
| `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]` |
|
| `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
|
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||||
reachable from inside only.
|
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
|
### Names that disagree with themselves
|
||||||
|
|
||||||
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
|
Three hosts answer to more than one name, and `tailscale status` is not always the one in use:
|
||||||
|
|||||||
@@ -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 runs on it. Read it at the start of every deploy, and treat the hosts marked as having no
|
||||||
platform as out of scope.
|
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.
|
**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
|
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.
|
`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
|
`--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
|
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.
|
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
|
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
|
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
|
something you have to notice. A failure here is usually an uncommitted change in that plugin's own
|
||||||
|
|||||||
Executable
+160
@@ -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)`);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export * from './ai-provider-usage';
|
|||||||
export * from './anthropic-accounts';
|
export * from './anthropic-accounts';
|
||||||
export * from './theme-favorites';
|
export * from './theme-favorites';
|
||||||
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
export { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
|
||||||
|
export * from './sendoff';
|
||||||
export * from './command-hq';
|
export * from './command-hq';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './integrations';
|
export * from './integrations';
|
||||||
|
|||||||
@@ -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 './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
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 './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
|
export * from './agent-panels/schema'; // agent_panels
|
||||||
|
|
||||||
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
|
// 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)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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 { 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 = safeSegment(me.name || me.email.split('@')[0] || `user-${me.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
return ctx.json({ devices, people });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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 });
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-p
|
|||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||||
import { minesweeperRouter } from './api/minesweeper/router';
|
import { minesweeperRouter } from './api/minesweeper/router';
|
||||||
|
import { sendoffRouter } from './api/sendoff/router';
|
||||||
import { caldavRouter } from './api/dav/sidecar-server';
|
import { caldavRouter } from './api/dav/sidecar-server';
|
||||||
// import { memosRouter } from './api/memos/router';
|
// import { memosRouter } from './api/memos/router';
|
||||||
import { appStoreRouter } from './api/app-store/router';
|
import { appStoreRouter } from './api/app-store/router';
|
||||||
@@ -141,6 +142,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
['/file-browser', fileBrowserRouter],
|
['/file-browser', fileBrowserRouter],
|
||||||
['/terminal', terminalRouter],
|
['/terminal', terminalRouter],
|
||||||
['/minesweeper', minesweeperRouter],
|
['/minesweeper', minesweeperRouter],
|
||||||
|
['/sendoff', sendoffRouter],
|
||||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||||
['/app-store', appStoreRouter],
|
['/app-store', appStoreRouter],
|
||||||
['/plugins', pluginsRouter],
|
['/plugins', pluginsRouter],
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ const CORE_ENTRIES: Record<string, NotificationEntry> = {
|
|||||||
route: 'officer://jobs/{id}',
|
route: 'officer://jobs/{id}',
|
||||||
thread: 'officer/core:job',
|
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': {
|
'officer/core:job-failed': {
|
||||||
title: 'Job failed',
|
title: 'Job failed',
|
||||||
target: 'mobile',
|
target: 'mobile',
|
||||||
|
|||||||
@@ -347,6 +347,27 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
ws: ['minesweeper'],
|
ws: ['minesweeper'],
|
||||||
routes: ['/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',
|
key: 'items',
|
||||||
label: 'Permission authoring',
|
label: 'Permission authoring',
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user