Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0788d15c0 | ||
|
|
7704867e05 | ||
|
|
04a4ab33dd | ||
|
|
b640ec486d | ||
|
|
0bd8c6c16a | ||
|
|
6ac9187c52 | ||
|
|
dcd11a3b17 | ||
|
|
3dd37d053b | ||
|
|
64894ad96e | ||
|
|
0a8fe1c2c8 | ||
|
|
2137d06498 | ||
|
|
ca7d3c657b | ||
|
|
b22e9d86c0 | ||
|
|
2095b6a717 | ||
|
|
4ea25d76c8 | ||
|
|
b94a5e90b8 |
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -210,6 +210,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
"hooks": "*",
|
"hooks": "*",
|
||||||
|
"i18n": "*",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
@@ -271,6 +272,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
|
"i18n": "workspace:*",
|
||||||
"state": "workspace:*",
|
"state": "workspace:*",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026.08.23.02 — deploy report
|
||||||
|
|
||||||
|
**5 of 5 target hosts succeeded.** Two hosts were not targets: `officer-dev` (the rollout ran from it)
|
||||||
|
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`).
|
||||||
|
|
||||||
|
Every target went the same way, in the plan's order: `git fetch origin --tags`, `git merge --ff-only
|
||||||
|
deploy/2026.08.23.02`, `bun db:push`, `bun run plugins:update`, `pm2 restart officer`. No tree was dirty
|
||||||
|
in tracked files, so no host was skipped for that. Every host fast-forwarded cleanly to
|
||||||
|
`0a8fe1c2` — the tag commit, and exactly that commit on all five.
|
||||||
|
|
||||||
|
The tag and `package.json` agree (`2026.08.23.02`), and the plan file was present at the tag.
|
||||||
|
|
||||||
|
## Per host
|
||||||
|
|
||||||
|
| Host | HEAD before | HEAD after | db:push | plugins:update | `officer` restart_time | Result |
|
||||||
|
| ---------------------------- | ----------- | ---------- | ------- | -------------- | ---------------------- | ------- |
|
||||||
|
| `alpha` | `e6f1388a` | `0a8fe1c2` | applied | 12/12 in 47s | 67 → 68, online | ok |
|
||||||
|
| `fofos` | `e6f1388a` | `0a8fe1c2` | applied | 1/1 in 7s | 21 → 22, online | ok |
|
||||||
|
| `macbookpro` | `b22e9d86` | `0a8fe1c2` | applied | no plugins | 8 → 9, online | ok |
|
||||||
|
| `production-pertento-bridge` | `68d60e40` | `0a8fe1c2` | applied | 1/1 in 5s | 0 → 1, online | ok |
|
||||||
|
| `deadpool-rezio-bridge` | `e6f1388a` | `0a8fe1c2` | applied | 2/2 in 7s | 6 → 7, online | ok |
|
||||||
|
| `officer-dev` | — | — | — | — | — | SKIPPED |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | — | SKIPPED |
|
||||||
|
|
||||||
|
`bun db:push` reported `Changes applied` on all five. It prompted for nothing — `sendoff_devices` and
|
||||||
|
`sendoff_senders` are new tables, so the diff was additive.
|
||||||
|
|
||||||
|
## What restarted, beyond what the plan asked for
|
||||||
|
|
||||||
|
The plan named `officer` only, and `officer` is the only process this deploy restarted deliberately.
|
||||||
|
`bun run plugins:update` restarts each plugin's own sidecar as it goes — that is the script's behaviour,
|
||||||
|
not a fault, and it is the larger part of what moved:
|
||||||
|
|
||||||
|
- `alpha` — twelve plugin sidecars bounced during the plugins step (atelier, bitcoin-wallet, email,
|
||||||
|
gitea, invoices, jellyfin, music, offscale, photos, soulseek, transmission, vault).
|
||||||
|
- `fofos` — one (`officer-gitea`).
|
||||||
|
- `production-pertento-bridge` — one (`officer-gitea`).
|
||||||
|
- `deadpool-rezio-bridge` — two (`officer-email`, `officer-offscale`).
|
||||||
|
- `macbookpro` — none; no plugins are installed there.
|
||||||
|
|
||||||
|
After each restart, `pm2 jlist` showed `officer` `online` with `restart_time` incremented, and no process
|
||||||
|
on any host was `errored`.
|
||||||
|
|
||||||
|
## Hosts the plan flagged
|
||||||
|
|
||||||
|
- **`production-pertento-bridge`** was one release behind (`68d60e40`, which is `deploy/2026.08.22.02`)
|
||||||
|
and fast-forwarded past `deploy/2026.08.23.01` to this tag in one step. Its `known_hosts` entry was
|
||||||
|
absent as expected; the first connection pinned the ED25519 key it offered. Expected, per the plan.
|
||||||
|
- **`edge-pertento-bridge`** was not contacted at all. Nothing was spent on it.
|
||||||
|
- **`macbookpro`** was awake and needed the documented PATH append (`/opt/homebrew/bin`) for the `bun`
|
||||||
|
steps, with `pm2` run separately through `$SHELL -ic`.
|
||||||
|
|
||||||
|
## Things worth knowing, none of them failures
|
||||||
|
|
||||||
|
- `fofos` carries one untracked file, `scripts/chk.ts`. A note, not a skip — a fast-forward cannot touch
|
||||||
|
it, and it was left alone.
|
||||||
|
- `production-pertento-bridge` prints an nvm/`.npmrc` `globalconfig` warning before every command run
|
||||||
|
through `$SHELL -ic`. It is noise; every command it prefixed succeeded.
|
||||||
|
- `macbookpro` prints `command not found: starship` on stderr from `.zshrc`. Harmless, but it corrupts
|
||||||
|
`pm2 jlist` output if stderr is merged into stdout — read that host's JSON with `2>/dev/null`.
|
||||||
|
|
||||||
|
## What was not done
|
||||||
|
|
||||||
|
- **`officer-dev` was not deployed.** Its checkout is already at `0a8fe1c2` (the release was cut here),
|
||||||
|
but nothing was pulled, no schema was pushed, no plugin was updated and no process was restarted. The
|
||||||
|
rollout ran from this machine, and restarting `officer` here ends the agent doing it. That host needs
|
||||||
|
a deliberate `bun db:push`, `bun run plugins:update` and `pm2 restart officer` from the owner before
|
||||||
|
`/sendoff` will work on it — the schema change means the missing `bun db:push` is not cosmetic.
|
||||||
|
- No host got `bun install`. The plan forbade it: `bun.lock` is unchanged across this release.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 2026.08.23.03
|
||||||
|
|
||||||
|
Roles become a table, and the localization infrastructure lands. Both are groundwork — nothing in the UI
|
||||||
|
changes and nothing reads the new table yet.
|
||||||
|
|
||||||
|
**SCHEMA CHANGE.** `bun db:push` is a step, and `postdb:push` now seeds the new table automatically. The
|
||||||
|
`roles` table is new. Additive only: no constraint is added to an existing column, so this cannot fail on
|
||||||
|
existing data.
|
||||||
|
|
||||||
|
**No dependency change.** `bun.lock` gained two lines and they are the local `i18n` workspace package being
|
||||||
|
declared as a dependency of `officerdev` — a workspace path, not a registry fetch. **Do not run
|
||||||
|
`bun install`.**
|
||||||
|
|
||||||
|
## What is in it
|
||||||
|
|
||||||
|
- **`roles` is a table**, seeded with the four the platform already had, each marked `isSystem` because the
|
||||||
|
code names it: `rolePermitsDevTools()` tests for `Developer`, bootstrap writes `Super Admin`, `users.role`
|
||||||
|
defaults to `Member`. Nothing reads the table yet — `USER_ROLES` is still the type and `authorize.ts` is
|
||||||
|
untouched. **There is deliberately no foreign key on `users.role`**; that change cannot go through
|
||||||
|
`db:push` and is a later release.
|
||||||
|
- **The seed runs from `postdb:push`**, so a host gets the four rows on its next schema push with no extra
|
||||||
|
step here. It never overwrites an existing row.
|
||||||
|
- **Localization phase 0** — plural selection through `Intl.PluralRules`, a `useFormat()` hook for dates and
|
||||||
|
numbers, three namespaced catalogs deep-merged at the bridge, `dir` on `<html>`, and key-parity tests that
|
||||||
|
fail the build on a missing key. The language picker now offers only the two catalogs that exist rather
|
||||||
|
than twenty codes, eighteen of which silently rendered English. Stored bare codes widen on read
|
||||||
|
(`en` → `en-GB`), with no database migration.
|
||||||
|
- **SendOff's folder name is served rather than derived** — `/sendoff/targets` carries `displayName` and
|
||||||
|
`myFolder` from the same helper the writer uses, so a picker cannot name a folder the writer will not
|
||||||
|
create.
|
||||||
|
|
||||||
|
## Every host
|
||||||
|
|
||||||
|
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.03`
|
||||||
|
2. **`bun db:push`** — and per the procedure, **verify by query, not by exit code.** Check that `roles`
|
||||||
|
exists and holds four rows on that host. `db:push` can fail and still exit 0.
|
||||||
|
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.
|
||||||
|
|
||||||
|
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||||
|
|
||||||
|
## The check that matters this release
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select count(*) from roles; -- expect 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Four rows named `Super Admin`, `Admin`, `Developer`, `Member`. If a host shows the table but zero rows, the
|
||||||
|
`postdb:push` hook did not run — report it as a failure for that host rather than as a success, and say so
|
||||||
|
per host in the report.
|
||||||
|
|
||||||
|
No direct SQL is needed and none is authorised for this release.
|
||||||
|
|
||||||
|
## Hosts to expect trouble from
|
||||||
|
|
||||||
|
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||||
|
- **`production-pertento-bridge`** — came back last release after two behind. Nothing special expected.
|
||||||
|
|
||||||
|
## `officer-dev` — skip it
|
||||||
|
|
||||||
|
The rollout runs from here. It already holds the release and the four rows, verified locally.
|
||||||
+57
-36
@@ -28,13 +28,13 @@ the server does instead, which is the one decision that reaches outside this bou
|
|||||||
There is already an i18n system. It is small, hand-rolled, has no npm dependency, and it works. It is
|
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.
|
also about **5% deployed**, and that gap is the whole job.
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| ----------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
| `src/workspaces/i18n/` | the package: `I18nProvider`, `useTranslation`, `resolveKey`, `interpolate` |
|
| `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/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/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** |
|
| `src/apps/officer-web/locales/` | `en.json` and `pt.json` — **46 keys each, in sync** |
|
||||||
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
| components actually using it | **3** — `UserMenu`, `Languages`, `ChangePassword` |
|
||||||
|
|
||||||
What it already does well, and which this plan keeps: dot-path lookup, `{{param}}` interpolation,
|
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
|
fallback to `en`, and **type-safe keys** via `TranslationKeys<typeof en>` — a wrong key is a
|
||||||
@@ -45,7 +45,7 @@ typecheck failure, which is worth more than it sounds when one agent is editing
|
|||||||
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
1. **No plural rules.** `1 file` / `2 files` has no expression. `Intl.PluralRules` is built into the
|
||||||
runtime and unused.
|
runtime and unused.
|
||||||
2. **No locale-aware dates or numbers.** ~23 `toLocaleDateString/TimeString/String()` call sites use
|
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.
|
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
|
3. **No fallback chain.** `resolveKey` falls back to `en` and nothing else, so `pt-BR` cannot inherit
|
||||||
from `pt`.
|
from `pt`.
|
||||||
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
4. **One catalog, one workspace.** `locales/` lives inside `officer-web`. The 213 panel files in
|
||||||
@@ -59,12 +59,12 @@ typecheck failure, which is worth more than it sounds when one agent is editing
|
|||||||
|
|
||||||
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
Measured, not guessed — `bun scripts/i18n/scan-hardcoded.ts`:
|
||||||
|
|
||||||
| area | files | occurrences |
|
| area | files | occurrences |
|
||||||
| --- | --- | --- |
|
| --------------------- | ------- | ----------- |
|
||||||
| `officer-web` (shell) | 132 | 556 |
|
| `officer-web` (shell) | 132 | 556 |
|
||||||
| `officerdev` (panels) | 213 | 567 |
|
| `officerdev` (panels) | 213 | 567 |
|
||||||
| shared `components` | 91 | 47 |
|
| shared `components` | 91 | 47 |
|
||||||
| **total** | **436** | **1170** |
|
| **total** | **436** | **1170** |
|
||||||
|
|
||||||
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
**884 distinct strings**, of which **88 need interpolation**. Against 46 keys today.
|
||||||
|
|
||||||
@@ -101,29 +101,46 @@ precise to quote. Prose drifts; a code does not. And the server stops needing to
|
|||||||
refusal is deliberately vague, the code has to stay equally vague. `officer-secret-store` returns 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
|
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
|
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.
|
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
|
### D2. Regional locales, BCP 47, and every catalog is complete
|
||||||
|
|
||||||
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it,
|
`pt-PT`, `pt-BR`, `en-GB`, `en-US` — not bare `pt` and `en`. Portuguese is the case that forces it, and
|
||||||
and it is the owner's own language, so getting it wrong is felt immediately.
|
it is the owner's own language, so getting it wrong is felt immediately.
|
||||||
|
|
||||||
Resolution walks a chain and stops at the first hit:
|
**Every locale ships a COMPLETE catalog. There is no regional inheritance and no overlay.** A file holds
|
||||||
|
every key the UI has, translated for that locale, or it does not ship.
|
||||||
|
|
||||||
```
|
This reverses an earlier version of this decision, which had `pt-BR` inherit from `pt` so a base catalog
|
||||||
pt-BR → pt → en
|
carried the shared bulk and a regional file carried only the differences. That is the conventional design
|
||||||
en-GB → en
|
and it was rejected for a product reason that outranks the engineering one:
|
||||||
```
|
|
||||||
|
|
||||||
So a **base catalog carries the shared bulk and a regional overlay carries only the differences.**
|
**A missing European Portuguese key would have fallen through to Brazilian.** European and Brazilian
|
||||||
`pt.json` holds everything both Portugueses agree on; `pt-BR.json` holds only what Brazil says
|
Portuguese differ most in exactly the vocabulary this UI is full of — technical terms — and the platform is
|
||||||
differently. Two near-identical full catalogs would drift within a month.
|
being sold into the European Portuguese market first. So the failure mode of an overlay is a Portuguese
|
||||||
|
reader getting Brazilian phrasing on whichever strings nobody remembered to override, silently, and with no
|
||||||
|
signal that it happened. Owner's call, and it is the right one: a reader must never be served a neighbouring
|
||||||
|
variant's words.
|
||||||
|
|
||||||
|
The engineering objection to full catalogs was drift — two near-identical files diverging within a month.
|
||||||
|
That is answered by making the **key-parity test load-bearing** rather than advisory (see Tooling): a locale
|
||||||
|
missing a key that `en` has fails the build, so an incomplete catalog cannot ship. Completeness is enforced
|
||||||
|
by the test rather than by a fallback quietly papering over a gap.
|
||||||
|
|
||||||
|
It is also less code. `resolveKey` looks in the active locale and then, only if the key is genuinely absent,
|
||||||
|
in `en` — which per D8 is a bug the parity test should already have caught. There is no chain to walk, no
|
||||||
|
base-versus-overlay merge order, and no question about which file a given string lives in.
|
||||||
|
|
||||||
`Intl` takes BCP 47 tags natively, so dates, numbers and plurals come out right with no extra work.
|
`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
|
**Migration:** `settings.languages.default` stores bare codes today (`pt`). The picker must move to regional
|
||||||
regional tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer
|
tags, and stored values need a one-time widening (`pt` → `pt-PT`, `en` → `en-GB`). Prefer widening on read
|
||||||
widening on read over a database migration — a stale bare code should resolve, not fail.
|
over a database migration — a stale bare code should resolve, not fail.
|
||||||
|
|
||||||
|
**Consequence for translation work:** a new locale is a whole catalog, not a diff. Machine-translate the
|
||||||
|
full file and have it reviewed, rather than hand-writing overrides against a base. For `pt-PT` and `pt-BR`
|
||||||
|
that means two complete passes, reviewed separately, because the whole point is that neither borrows from
|
||||||
|
the other.
|
||||||
|
|
||||||
### D3. RTL — prepare, do not ship
|
### D3. RTL — prepare, do not ship
|
||||||
|
|
||||||
@@ -148,7 +165,7 @@ The gap is smaller than it looks, because the hard parts are in the runtime alre
|
|||||||
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
`Intl.DateTimeFormat`, `Intl.NumberFormat`, `Intl.RelativeTimeFormat`. What is missing is roughly 150
|
||||||
lines of glue, listed in "What it cannot do yet".
|
lines of glue, listed in "What it cannot do yet".
|
||||||
|
|
||||||
Revisit this only if message *formatting* gets genuinely complex — nested selects, gendered forms.
|
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.
|
ICU MessageFormat is a real specification and reimplementing it would be the wrong kind of ambition.
|
||||||
|
|
||||||
### D5. Keys, not English source text
|
### D5. Keys, not English source text
|
||||||
@@ -223,7 +240,7 @@ app.errors.MINESWEEPER_NOT_RUNNING
|
|||||||
- `namespace` — `app` / `panel` / `ui`, per D6.
|
- `namespace` — `app` / `panel` / `ui`, per D6.
|
||||||
- `area` — the route or panel it belongs to; matches the directory.
|
- `area` — the route or panel it belongs to; matches the directory.
|
||||||
- `component` — the file, lowercased.
|
- `component` — the file, lowercased.
|
||||||
- `element` — what it *is*: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
- `element` — what it _is_: `title`, `description`, `placeholder`, `confirmLabel`, `emptyState`.
|
||||||
|
|
||||||
Rules that keep 1200 keys navigable:
|
Rules that keep 1200 keys navigable:
|
||||||
|
|
||||||
@@ -245,7 +262,7 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
|||||||
|
|
||||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
- **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*
|
- **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
|
(the code is the key; its message is what gets translated), app ids, sidecar names, PM2 process
|
||||||
names, database values.
|
names, database values.
|
||||||
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
- **Log lines and thrown `Error` messages** that are not rendered to a user. They are for whoever is
|
||||||
@@ -304,9 +321,13 @@ Without these, 1200 keys rot. All three are small and belong in phase 0.
|
|||||||
hardcoded string by area and file. It is how the numbers above were produced, and it is the progress
|
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.
|
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
|
**A key-parity test — and per D2 this is now load-bearing, not a nicety.** For each shipped locale, every
|
||||||
chain. Fails the build on a gap, the same way `bun docs:check` fails on a stale number. This is the
|
key that `en` has must be present IN THAT LOCALE'S OWN FILE. Fails the build on a gap, the same way
|
||||||
one guard that stops a locale silently degrading into English.
|
`bun docs:check` fails on a stale number.
|
||||||
|
|
||||||
|
With full catalogs and no overlays, this test is the only thing standing between "complete translation" and
|
||||||
|
"silently English in patches". It replaces the fallback that used to hide a gap, so it has to be as
|
||||||
|
unavoidable as the typecheck: a missing key is not a warning, it is a red build.
|
||||||
|
|
||||||
**A no-new-hardcoded-strings check.** Once an area reaches zero, add it to an allow-list the scanner
|
**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
|
enforces, so the next feature cannot reintroduce debt. Add this per area as it completes — enabling
|
||||||
@@ -339,7 +360,7 @@ else will ever notice.
|
|||||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
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
|
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||||
manifest. Their strings are not counted above.
|
manifest. Their strings are not counted above.
|
||||||
4. **The `translateTo` setting already in `settings.languages`** is about translating *content*, not
|
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.
|
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
|
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.
|
between accounts needs a locale decision. D1 mostly removes this by removing server prose.
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "officer",
|
"name": "officer",
|
||||||
"version": "2026.08.23.01",
|
"version": "2026.08.23.03",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts",
|
"postdb:push": "bun run --env-file=.env scripts/backfill-session-models.ts && bun run --env-file=.env scripts/seed-roles.ts",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||||
|
|||||||
@@ -192,6 +192,51 @@ assemble at the end from memory is a report that will be wrong about the host th
|
|||||||
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
If a host fails, **continue to the next one**. A half-rolled-out release that is honestly reported beats
|
||||||
one that stopped in the middle and said nothing about the four hosts it never reached.
|
one that stopped in the middle and said nothing about the four hosts it never reached.
|
||||||
|
|
||||||
|
### Schema changes, and why `db:push` cannot be trusted
|
||||||
|
|
||||||
|
**`drizzle-kit push` can fail and still exit 0.** Verified on 2026-08-23 against a scratch database: a
|
||||||
|
change that violated a foreign key printed the Postgres error, created part of the change, left the rest
|
||||||
|
undone, and returned zero. `bun db:push` wraps it, so a plan step that reads `bun db:push` can report
|
||||||
|
success while the schema is half-applied.
|
||||||
|
|
||||||
|
So on any release whose plan says the schema changed:
|
||||||
|
|
||||||
|
**Never take the exit code as the answer. Check the database.** Query for the specific thing the plan
|
||||||
|
introduced — the table, the column — on that host, against that host's own database. `Changes applied` on
|
||||||
|
stdout is not evidence, and neither is exit 0. A report that says "schema ✓" without a query behind it is
|
||||||
|
a guess.
|
||||||
|
|
||||||
|
**Some changes cannot go through `db:push` at all, and the plan should say so.** The shape that fails is
|
||||||
|
create-then-constrain: a new table plus a foreign key onto it from an existing column. `push` creates the
|
||||||
|
table empty and adds the constraint in the same run, so every existing row violates it. There is no
|
||||||
|
ordering of `push` invocations that fixes this, because the seed has to happen between the two halves.
|
||||||
|
|
||||||
|
**When the plan says the schema needs direct SQL — last resort, and only when the plan says it.**
|
||||||
|
|
||||||
|
This is allowed and it is not a licence to improvise. The rules, in order:
|
||||||
|
|
||||||
|
1. **The Drizzle schema is the source of truth, always.** The SQL you run exists to make the database match
|
||||||
|
what the schema code already says. If you find yourself writing SQL the schema does not describe, stop —
|
||||||
|
that is a code change and it is not yours to make on five production hosts at 3am.
|
||||||
|
2. **The plan must contain the exact SQL.** Written by whoever cut the release, reviewed before the tag.
|
||||||
|
Do not compose it yourself from the diff, and do not adapt it per host: same statements, every host, or
|
||||||
|
the fleet has diverged in a way nothing will detect.
|
||||||
|
3. **Idempotent, or guarded.** `create table if not exists`, `insert … on conflict do nothing`,
|
||||||
|
`alter table … add constraint` only after checking `pg_constraint`. A deploy that is safe to run twice
|
||||||
|
is a deploy that can be retried, and being retryable is what stops a partial rollout becoming a
|
||||||
|
forensic exercise.
|
||||||
|
4. **Then run `bun db:push` anyway**, and expect it to be a no-op. That is the proof the two agree: if
|
||||||
|
`push` still wants to change something after your SQL, the SQL was wrong or incomplete, and that is a
|
||||||
|
finding for the report rather than something to push past.
|
||||||
|
5. **Verify per host, by query**, and put the result in the report per host. Not "applied on all five" —
|
||||||
|
the specific check, and its answer, for each.
|
||||||
|
6. **A host where the SQL fails is a failed host.** Report it, move to the next, and do not attempt a
|
||||||
|
repair you invented.
|
||||||
|
|
||||||
|
**One thing to refuse outright:** `--force`. It exists to answer prompts unattended, and the prompts it
|
||||||
|
answers are the destructive ones. On five hosts holding real data, a flag whose whole job is to say yes to
|
||||||
|
"this will truncate a table" is not a convenience. If a change needs `--force` to apply, it needs a human.
|
||||||
|
|
||||||
### Skip the host you are running on
|
### Skip the host you are running on
|
||||||
|
|
||||||
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
Entirely. Do not pull it, do not restart anything on it, and **name it in the report as skipped**.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Seed the `roles` table with the roles the platform's own code names.
|
||||||
|
*
|
||||||
|
* Runs from `postdb:push`, so every machine gets the rows on its next schema push rather than needing a
|
||||||
|
* per-server step in a deploy plan. Bun fires `pre`/`post` hooks for colon-named scripts — the same
|
||||||
|
* mechanism `backfill-session-models.ts` relies on.
|
||||||
|
*
|
||||||
|
* ── Why these four are `isSystem` ──
|
||||||
|
*
|
||||||
|
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||||
|
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||||
|
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||||
|
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||||
|
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||||
|
*
|
||||||
|
* ── What this deliberately does NOT do ──
|
||||||
|
*
|
||||||
|
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||||
|
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||||
|
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||||
|
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||||
|
*
|
||||||
|
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||||
|
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||||
|
* unattended.
|
||||||
|
*
|
||||||
|
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||||
|
*/
|
||||||
|
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||||
|
|
||||||
|
const SEED: { name: string; description: string }[] = [
|
||||||
|
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||||
|
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||||
|
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||||
|
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await waitForDatabase();
|
||||||
|
let created = 0;
|
||||||
|
for (const role of SEED) {
|
||||||
|
if (await seedRole({ ...role, isSystem: true })) created += 1;
|
||||||
|
}
|
||||||
|
console.log(`[seed:roles] ${created} seeded, ${SEED.length - created} already present (of ${SEED.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never fatal. This runs from a `post` hook after a schema push, and a seed that cannot write must not make
|
||||||
|
// a successful push look like a failed one — the rows are additive and the next push tries again.
|
||||||
|
main()
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[seed:roles] could not seed:', err instanceof Error ? err.message : err);
|
||||||
|
})
|
||||||
|
.finally(() => process.exit(0));
|
||||||
@@ -107,6 +107,7 @@ export function App() {
|
|||||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||||
|
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Radar,
|
Radar,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
|||||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
{ 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
|
// 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
|
// 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
|
// 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';
|
||||||
@@ -3,6 +3,7 @@ import { X } from 'lucide-react';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
|
import { SHIPPED_LOCALES } from '@/lib/locales';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', label: 'English' },
|
{ code: 'en', label: 'English' },
|
||||||
@@ -56,7 +57,8 @@ export const Languages = () => {
|
|||||||
const removeSpoken = (code: string) => {
|
const removeSpoken = (code: string) => {
|
||||||
const next = spoken.filter((s) => s !== code);
|
const next = spoken.filter((s) => s !== code);
|
||||||
const updates = { ...settings.languages, spoken: next };
|
const updates = { ...settings.languages, spoken: next };
|
||||||
if (defaultLang === code) updates.default = next[0] ?? 'en';
|
// `default` (UI localization) draws from SHIPPED_LOCALES, not `spoken` — removing a spoken
|
||||||
|
// language never leaves it without a valid value.
|
||||||
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
if (translateTo === code) updates.translateTo = next[0] ?? 'en';
|
||||||
save(updates);
|
save(updates);
|
||||||
};
|
};
|
||||||
@@ -104,7 +106,7 @@ export const Languages = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Default language */}
|
{/* Default language — UI localization, so it only offers locales that actually ship (D2) */}
|
||||||
<Label className="grid gap-2">
|
<Label className="grid gap-2">
|
||||||
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
|
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -112,9 +114,9 @@ export const Languages = () => {
|
|||||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||||
>
|
>
|
||||||
{spoken.map((code) => (
|
{SHIPPED_LOCALES.map(({ code, label }) => (
|
||||||
<option key={code} value={code}>
|
<option key={code} value={code}>
|
||||||
{getLabel(code)}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ export * from './Terminal';
|
|||||||
export * from './Jobs';
|
export * from './Jobs';
|
||||||
export * from './QrTransfer';
|
export * from './QrTransfer';
|
||||||
export * from './Minesweeper';
|
export * from './Minesweeper';
|
||||||
|
export * from './SendOff';
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { TranslationMap } from 'i18n';
|
import type { TranslationMap } from 'i18n';
|
||||||
import { I18nProvider } from 'i18n';
|
import { I18nProvider, localeDirection } from 'i18n';
|
||||||
|
import { panelCatalogs } from 'officerdev';
|
||||||
|
import { uiCatalogs } from '@/components/locales';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import en from '../locales/en.json';
|
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||||
import pt from '../locales/pt.json';
|
import appEnGB from '../locales/en-GB.json';
|
||||||
|
import appPtPT from '../locales/pt-PT.json';
|
||||||
|
|
||||||
const translations: TranslationMap = { en, pt };
|
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||||
|
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||||
|
*/
|
||||||
|
const translations: TranslationMap = Object.fromEntries(
|
||||||
|
SHIPPED_LOCALES.map(({ code }) => [
|
||||||
|
code,
|
||||||
|
{
|
||||||
|
app: appCatalogs[code] ?? {},
|
||||||
|
panel: panelCatalogs[code] ?? {},
|
||||||
|
ui: uiCatalogs[code] ?? {},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
type I18nBridgeProps = {
|
type I18nBridgeProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -15,8 +34,14 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const locale = settings.languages.default;
|
const locale = settings.languages.default;
|
||||||
|
|
||||||
|
// Prepared for RTL (D3) — every shipped locale is `ltr` today, but the wiring exists so a future
|
||||||
|
// RTL catalog needs no second pass over the tree.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dir = localeDirection(locale);
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nProvider translations={translations} locale={locale}>
|
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||||
{children}
|
{children}
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { TranslationKeys } from 'i18n';
|
import type { TranslationKeys } from 'i18n';
|
||||||
import type { InterpolationParams } from 'i18n';
|
import type { InterpolationParams } from 'i18n';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from '../locales/en.json';
|
import type en from '../locales/en-GB.json';
|
||||||
|
|
||||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
|
||||||
|
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
|
// pass the same bare key the JSON file uses.
|
||||||
export const useTranslation = () => {
|
export const useTranslation = () => {
|
||||||
const { t: baseT, locale } = useTranslationBase();
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
|
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
|
||||||
return { t, locale };
|
return { t, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* The UI locales that actually ship — regional BCP 47 tags, per docs/localization.md D2. This is the
|
||||||
|
* one list `I18nBridge` merges catalogs for and `Languages.tsx` offers for UI localization; it is
|
||||||
|
* deliberately separate from the broader "languages you speak" / "translate to" lists, which are a
|
||||||
|
* different feature (see the doc's open question 4) and are not limited to locales with a catalog.
|
||||||
|
*/
|
||||||
|
export type ShippedLocale = { code: string; label: string };
|
||||||
|
|
||||||
|
export const SHIPPED_LOCALES: ShippedLocale[] = [
|
||||||
|
{ code: 'en-GB', label: 'English (UK)' },
|
||||||
|
{ code: 'pt-PT', label: 'Português (Portugal)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FALLBACK_LOCALE = 'en-GB';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { findMissingKeys } from 'i18n';
|
||||||
|
import en from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||||
|
// not a warning — there is no regional fallback to paper over the gap.
|
||||||
|
describe('officer-web (app.*) locale catalogs', () => {
|
||||||
|
test('pt-PT has every key en-GB has', () => {
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,8 @@ 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 './roles';
|
||||||
export * from './command-hq';
|
export * from './command-hq';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
export * from './integrations';
|
export * from './integrations';
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { roles } from './schema';
|
||||||
|
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { users } from '../auth/schema';
|
||||||
|
import { roles } from './schema';
|
||||||
|
|
||||||
|
export type RoleRow = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
provisioningScript: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every role, with how many accounts hold each.
|
||||||
|
*
|
||||||
|
* The count is here rather than in a second query because every caller needs it: a picker wants to show
|
||||||
|
* it, and the delete guard is defined in terms of it. One left join beats two round trips and a race
|
||||||
|
* between them.
|
||||||
|
*/
|
||||||
|
export async function listRoles(): Promise<(RoleRow & { userCount: number })[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
name: roles.name,
|
||||||
|
description: roles.description,
|
||||||
|
isSystem: roles.isSystem,
|
||||||
|
provisioningScript: roles.provisioningScript,
|
||||||
|
createdAt: roles.createdAt,
|
||||||
|
userCount: sql<number>`count(${users.id})::int`,
|
||||||
|
})
|
||||||
|
.from(roles)
|
||||||
|
.leftJoin(users, eq(users.role, roles.name))
|
||||||
|
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||||
|
.orderBy(asc(roles.name));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||||
|
const [row] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||||
|
*
|
||||||
|
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||||
|
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||||
|
* deploy — so the seed establishes a row and never touches it again.
|
||||||
|
*/
|
||||||
|
export async function seedRole(row: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
isSystem?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const inserted = await db
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||||
|
.onConflictDoNothing({ target: roles.name })
|
||||||
|
.returning({ name: roles.name });
|
||||||
|
return inserted.length > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roles this server has, as data rather than a TypeScript union.
|
||||||
|
*
|
||||||
|
* ── Why the NAME is the primary key ──
|
||||||
|
*
|
||||||
|
* `users.role` and `role_permissions.role` both hold a role name today, and `authorize.ts` reads a name off
|
||||||
|
* the user to look up grants. Re-keying those onto a serial id would touch the authorization path — the one
|
||||||
|
* file standing between a Member and a shell — to buy nothing the name does not already give, because
|
||||||
|
* Postgres will cascade a rename for us. So both columns become foreign keys onto this one with
|
||||||
|
* `ON UPDATE CASCADE`: renaming a role moves every user and every grant with it, atomically, and no code
|
||||||
|
* changes.
|
||||||
|
*
|
||||||
|
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||||
|
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||||
|
*
|
||||||
|
* ── What is NOT here ──
|
||||||
|
*
|
||||||
|
* **Ownership.** The owner is `OWNER_USER_ID`, an identity, not a role — see `super-admin.ts`. Before this
|
||||||
|
* table existed, "who owns this server" was answered by `users.role === 'Super Admin'`, which meant that
|
||||||
|
* making roles editable would have made "add a role" and "become the owner" the same feature. Several
|
||||||
|
* accounts may now hold `Super Admin`; exactly one account is the owner, and no role grants it.
|
||||||
|
*
|
||||||
|
* **Permissions.** They stay in `role_permissions`, keyed on the name. A role row is the role's identity and
|
||||||
|
* its provisioning, not its grants.
|
||||||
|
*/
|
||||||
|
export const roles = pgTable('roles', {
|
||||||
|
/** The name, and the key. Referenced by `users.role` and `role_permissions.role` with ON UPDATE CASCADE. */
|
||||||
|
name: text('name').primaryKey(),
|
||||||
|
/** One line, for a picker. Never load-bearing — nothing branches on it. */
|
||||||
|
description: text('description'),
|
||||||
|
/**
|
||||||
|
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||||
|
*
|
||||||
|
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||||
|
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||||
|
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||||
|
* discovering it as a runtime failure somewhere else.
|
||||||
|
*/
|
||||||
|
isSystem: boolean('is_system').notNull().default(false),
|
||||||
|
/**
|
||||||
|
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||||
|
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||||
|
* becomes a row. Null today, and nothing reads it.
|
||||||
|
*
|
||||||
|
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||||
|
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||||
|
*/
|
||||||
|
provisioningScript: text('provisioning_script'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
@@ -37,6 +37,8 @@ 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 './roles/schema'; // roles
|
||||||
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,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}`;
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { TranslationMap } from 'i18n';
|
||||||
|
import enGB from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||||
|
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||||
|
|
||||||
|
export { useTranslation } from './useTranslation';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { findMissingKeys } from 'i18n';
|
||||||
|
import en from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||||
|
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
|
||||||
|
describe('shared components (ui.*) locale catalogs', () => {
|
||||||
|
test('pt-PT has every key en-GB has', () => {
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||||
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
|
import type en from './en-GB.json';
|
||||||
|
|
||||||
|
type UiTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
|
||||||
|
// The `ui.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
|
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
||||||
|
export const useTranslation = () => {
|
||||||
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
|
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
|
||||||
|
return { t, locale };
|
||||||
|
};
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"embla-carousel-react": "^8.5.1",
|
"embla-carousel-react": "^8.5.1",
|
||||||
"hooks": "*",
|
"hooks": "*",
|
||||||
|
"i18n": "*",
|
||||||
"input-otp": "^1.4.1",
|
"input-otp": "^1.4.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|||||||
@@ -11,15 +11,19 @@ export const I18nContext = createContext<I18nContextValue>({
|
|||||||
type I18nProviderProps = {
|
type I18nProviderProps = {
|
||||||
translations: TranslationMap;
|
translations: TranslationMap;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/** The catalog `resolveKey` falls back to on a miss — a bug path, per D8, not a regional chain. */
|
||||||
|
fallbackLocale?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
|
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||||
const value = useMemo<I18nContextValue>(() => {
|
const value = useMemo<I18nContextValue>(() => {
|
||||||
const t = (key: string, params?: InterpolationParams) =>
|
const t = (key: string, params?: InterpolationParams) => {
|
||||||
interpolate(resolveKey(translations, locale, key), params);
|
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||||
|
return interpolate(resolveKey(translations, locale, key, fallbackLocale, count), params);
|
||||||
|
};
|
||||||
return { t, locale };
|
return { t, locale };
|
||||||
}, [translations, locale]);
|
}, [translations, locale, fallbackLocale]);
|
||||||
|
|
||||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export { I18nProvider, I18nContext } from './I18nProvider';
|
export { I18nProvider, I18nContext } from './I18nProvider';
|
||||||
export { useTranslation } from './useTranslation';
|
export { useTranslation } from './useTranslation';
|
||||||
export { resolveKey, interpolate } from './resolve';
|
export { useFormat } from './useFormat';
|
||||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
|
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||||
|
export { findMissingKeys } from './parity';
|
||||||
|
export type { MissingKey } from './parity';
|
||||||
|
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { isPluralForms } from './resolve';
|
||||||
|
|
||||||
|
type Catalog = Record<string, unknown>;
|
||||||
|
|
||||||
|
const flattenKeys = (obj: Catalog, prefix = ''): string[] =>
|
||||||
|
Object.entries(obj).flatMap(([k, v]) => {
|
||||||
|
const path = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v !== null && typeof v === 'object' && !Array.isArray(v) && !isPluralForms(v)) {
|
||||||
|
return flattenKeys(v as Catalog, path);
|
||||||
|
}
|
||||||
|
return [path];
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MissingKey = { locale: string; key: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per D2, every shipped locale is a COMPLETE catalog — no regional inheritance. So a key `en` has
|
||||||
|
* and a locale's own file lacks is not a fallback case, it's a red build (docs/localization.md,
|
||||||
|
* "Tooling and guardrails").
|
||||||
|
*/
|
||||||
|
export const findMissingKeys = (en: Catalog, locales: Record<string, Catalog>): MissingKey[] => {
|
||||||
|
const enKeys = flattenKeys(en);
|
||||||
|
return Object.entries(locales).flatMap(([locale, catalog]) => {
|
||||||
|
const ownKeys = new Set(flattenKeys(catalog));
|
||||||
|
return enKeys.filter((key) => !ownKeys.has(key)).map((key) => ({ locale, key }));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||||
|
import { findMissingKeys } from './parity';
|
||||||
|
import type { TranslationMap } from './types';
|
||||||
|
|
||||||
|
describe('resolveKey', () => {
|
||||||
|
const translations: TranslationMap = {
|
||||||
|
'pt-PT': { greeting: 'Olá {{name}}' },
|
||||||
|
'en-GB': { greeting: 'Hello {{name}}', onlyInEnglish: 'English only' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('resolves from the active locale', () => {
|
||||||
|
expect(resolveKey(translations, 'pt-PT', 'greeting', 'en-GB')).toBe('Olá {{name}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the fallback locale on a miss', () => {
|
||||||
|
expect(resolveKey(translations, 'pt-PT', 'onlyInEnglish', 'en-GB')).toBe('English only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the raw key when even the fallback is missing', () => {
|
||||||
|
expect(resolveKey(translations, 'pt-PT', 'nowhere', 'en-GB')).toBe('nowhere');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never walks a chain between two non-fallback locales', () => {
|
||||||
|
const withThird: TranslationMap = { ...translations, 'pt-BR': {} };
|
||||||
|
// pt-BR has no key of its own and must NOT inherit pt-PT's — only the fallback locale counts.
|
||||||
|
expect(resolveKey(withThird, 'pt-BR', 'greeting', 'en-GB')).toBe('Hello {{name}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selects a plural branch via Intl.PluralRules', () => {
|
||||||
|
const plurals: TranslationMap = {
|
||||||
|
'en-GB': { files: { one: '{{count}} file', other: '{{count}} files' } },
|
||||||
|
};
|
||||||
|
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 1)).toBe('{{count}} file');
|
||||||
|
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 2)).toBe('{{count}} files');
|
||||||
|
expect(resolveKey(plurals, 'en-GB', 'files', 'en-GB', 0)).toBe('{{count}} files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interpolate', () => {
|
||||||
|
test('substitutes params', () => {
|
||||||
|
expect(interpolate('Hello {{name}}', { name: 'Ana' })).toBe('Hello Ana');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves an unknown placeholder untouched', () => {
|
||||||
|
expect(interpolate('Hello {{name}}', {})).toBe('Hello {{name}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes a plain string through with no params', () => {
|
||||||
|
expect(interpolate('Hello')).toBe('Hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPluralForms', () => {
|
||||||
|
test('accepts an open record of CLDR categories', () => {
|
||||||
|
expect(isPluralForms({ one: 'a', other: 'b' })).toBe(true);
|
||||||
|
expect(isPluralForms({ other: 'b' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a plain nested catalog node', () => {
|
||||||
|
expect(isPluralForms({ title: 'a', description: 'b' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-objects', () => {
|
||||||
|
expect(isPluralForms('a string')).toBe(false);
|
||||||
|
expect(isPluralForms(null)).toBe(false);
|
||||||
|
expect(isPluralForms(['one', 'other'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('localeDirection', () => {
|
||||||
|
test('ltr for English and Portuguese', () => {
|
||||||
|
expect(localeDirection('en-GB')).toBe('ltr');
|
||||||
|
expect(localeDirection('pt-PT')).toBe('ltr');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rtl for Arabic and Hebrew, by language subtag', () => {
|
||||||
|
expect(localeDirection('ar')).toBe('rtl');
|
||||||
|
expect(localeDirection('ar-EG')).toBe('rtl');
|
||||||
|
expect(localeDirection('he-IL')).toBe('rtl');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMissingKeys', () => {
|
||||||
|
test('flags a key en has and a locale lacks', () => {
|
||||||
|
const en = { common: { save: 'Save', cancel: 'Cancel' } };
|
||||||
|
const ptPT = { common: { save: 'Guardar' } };
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([{ locale: 'pt-PT', key: 'common.cancel' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a complete catalog has no gaps', () => {
|
||||||
|
const en = { common: { save: 'Save' } };
|
||||||
|
const ptPT = { common: { save: 'Guardar' } };
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plural-forms leaf counts as one key, not one per category', () => {
|
||||||
|
const en = { files: { one: '{{count}} file', other: '{{count}} files' } };
|
||||||
|
const ptPT = { files: { one: '{{count}} ficheiro', other: '{{count}} ficheiros' } };
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -2,6 +2,12 @@ export type TranslationMap = Record<string, Record<string, unknown>>;
|
|||||||
|
|
||||||
export type InterpolationParams = Record<string, string | number>;
|
export type InterpolationParams = Record<string, string | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pluralizable catalog entry — CLDR's category set (Intl.PluralRules), never a fixed
|
||||||
|
* `{one, other}` shape, because some locales need up to all six (see docs/localization.md D7).
|
||||||
|
*/
|
||||||
|
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>>;
|
||||||
|
|
||||||
export type I18nContextValue = {
|
export type I18nContextValue = {
|
||||||
t: (key: string, params?: InterpolationParams) => string;
|
t: (key: string, params?: InterpolationParams) => string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useContext, useMemo } from 'react';
|
||||||
|
import { I18nContext } from './I18nProvider';
|
||||||
|
|
||||||
|
type DateInput = Date | number | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date, time and number formatting bound to the active locale, via `Intl` (D7) — the replacement for
|
||||||
|
* the ~23 `toLocale*()` call sites that otherwise follow the browser's locale and quietly contradict
|
||||||
|
* the rest of the UI. Moving those call sites is phase 1-3 work; this is just the hook they move to.
|
||||||
|
*/
|
||||||
|
export const useFormat = () => {
|
||||||
|
const { locale } = useContext(I18nContext);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const date = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Intl.DateTimeFormat(locale, options).format(new Date(value));
|
||||||
|
|
||||||
|
const time = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { timeStyle: 'short', ...options }).format(new Date(value));
|
||||||
|
|
||||||
|
const dateTime = (value: DateInput, options?: Intl.DateTimeFormatOptions) =>
|
||||||
|
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', ...options }).format(new Date(value));
|
||||||
|
|
||||||
|
const number = (value: number, options?: Intl.NumberFormatOptions) =>
|
||||||
|
new Intl.NumberFormat(locale, options).format(value);
|
||||||
|
|
||||||
|
const relative = (value: number, unit: Intl.RelativeTimeFormatUnit, options?: Intl.RelativeTimeFormatOptions) =>
|
||||||
|
new Intl.RelativeTimeFormat(locale, options).format(value, unit);
|
||||||
|
|
||||||
|
return { date, time, dateTime, number, relative };
|
||||||
|
}, [locale]);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hooks": "workspace:*",
|
"hooks": "workspace:*",
|
||||||
|
"i18n": "workspace:*",
|
||||||
"state": "workspace:*",
|
"state": "workspace:*",
|
||||||
"widgets": "workspace:*"
|
"widgets": "workspace:*"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
|||||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||||
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
|
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
|
||||||
|
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||||
import type { QueryClient } from '@tanstack/react-query';
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
@@ -30,6 +31,7 @@ export const apps = [
|
|||||||
...monitorMetas,
|
...monitorMetas,
|
||||||
...qrTransferMetas,
|
...qrTransferMetas,
|
||||||
...minesweeperMetas,
|
...minesweeperMetas,
|
||||||
|
...sendOffMetas,
|
||||||
...davMetas,
|
...davMetas,
|
||||||
...commandHqMetas,
|
...commandHqMetas,
|
||||||
...appStoreMetas,
|
...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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './channels';
|
export * from './channels';
|
||||||
|
// The panel apps' own i18n catalog — I18nBridge merges it into the app-wide map under `panel.*`.
|
||||||
|
export { panelCatalogs } from './locales';
|
||||||
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||||
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
@@ -49,13 +51,11 @@ export type {
|
|||||||
CommandHqCostSummary,
|
CommandHqCostSummary,
|
||||||
} from './apps/CommandHQ';
|
} from './apps/CommandHQ';
|
||||||
|
|
||||||
|
|
||||||
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
|
// /calendar and /contacts select with a query param rather than a path segment, so what the screens need
|
||||||
// from the app is the param name and the link builder.
|
// from the app is the param name and the link builder.
|
||||||
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
export { COLLECTION_PARAM, davCollectionPath } from './apps/Dav/shared';
|
||||||
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
export type { DavCollection, CalendarEvent, DavContact } from './apps/Dav/shared';
|
||||||
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFilesAPI,
|
useFilesAPI,
|
||||||
useTasks,
|
useTasks,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { TranslationMap } from 'i18n';
|
||||||
|
import enGB from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
/** Panel apps' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `panel.*`. */
|
||||||
|
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||||
|
|
||||||
|
export { useTranslation } from './useTranslation';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { findMissingKeys } from 'i18n';
|
||||||
|
import en from './en-GB.json';
|
||||||
|
import ptPT from './pt-PT.json';
|
||||||
|
|
||||||
|
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||||
|
// (no panel is converted in this phase) — the test still runs so it is load-bearing from day one.
|
||||||
|
describe('officerdev (panel.*) locale catalogs', () => {
|
||||||
|
test('pt-PT has every key en-GB has', () => {
|
||||||
|
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||||
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
|
import type en from './en-GB.json';
|
||||||
|
|
||||||
|
type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
|
||||||
|
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
|
// pass the same bare key the JSON file uses.
|
||||||
|
export const useTranslation = () => {
|
||||||
|
const { t: baseT, locale } = useTranslationBase();
|
||||||
|
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
|
||||||
|
return { t, locale };
|
||||||
|
};
|
||||||
@@ -5,6 +5,13 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
|
|
||||||
const QUERY_KEY = ['USER_SETTINGS'];
|
const QUERY_KEY = ['USER_SETTINGS'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI locales went from bare codes to regional BCP 47 tags (docs/localization.md D2). A stale bare
|
||||||
|
* code must resolve rather than fail, and this is a one-time widening on read, not a DB migration.
|
||||||
|
*/
|
||||||
|
const WIDEN_BARE_LOCALE: Record<string, string> = { en: 'en-GB', pt: 'pt-PT' };
|
||||||
|
const widenLocale = (code: string): string => WIDEN_BARE_LOCALE[code] ?? code;
|
||||||
|
|
||||||
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
||||||
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
chat: { ...DEFAULT_SETTINGS.chat, ...saved.chat },
|
||||||
ai: {
|
ai: {
|
||||||
@@ -14,7 +21,11 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
|
|||||||
},
|
},
|
||||||
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
|
||||||
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
|
||||||
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
|
languages: {
|
||||||
|
...DEFAULT_SETTINGS.languages,
|
||||||
|
...saved.languages,
|
||||||
|
default: widenLocale(saved.languages?.default ?? DEFAULT_SETTINGS.languages.default),
|
||||||
|
},
|
||||||
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
tts: { ...DEFAULT_SETTINGS.tts, ...saved.tts },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +132,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
},
|
},
|
||||||
languages: {
|
languages: {
|
||||||
spoken: ['en'],
|
spoken: ['en'],
|
||||||
default: 'en',
|
default: 'en-GB',
|
||||||
translateTo: 'en',
|
translateTo: 'en',
|
||||||
},
|
},
|
||||||
tts: {
|
tts: {
|
||||||
|
|||||||
@@ -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