Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 9c8af9d424 deploy/2026.08.28.01 — sign in as a user
two commits, one feature, and a plan that leaves things out for stated reasons.

NO SCHEMA, NO DEPENDENCY, NO PLUGIN CHANGE — checked with git rather than
asserted, because asserting it is exactly what sank 2026.08.27.01.

db:push is deliberately not a step. yesterday's report established that it prints
"Changes applied" when it applies nothing, so its output is not evidence, and on
this fleet it re-applies a default on command_hq_sessions.reference_paths every
time plus churns pk_music_now_playing on two hosts. running it here would be
noise against a database this release does not touch.

so the rollout is a merge and one restart. the verification step is an
unauthenticated POST to the new route: 401 means mounted, 404 means the bundle
did not move — a check that works without a session, on an owner-only feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:03:44 +01:00
pastilhasandClaude Opus 5 fb0cc880ac sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for
me". reproducing that with a password reset means locking somebody out of their
own account in order to look at it, which is worse than the bug.

THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and
reaches every handler, because user-middleware puts the whole payload on the
context. without it an impersonated action is indistinguishable from that
person's own — in a log, in a transcript, in anything keyed on a user id — and a
support tool that can forge activity as somebody else is a different and much
worse thing than a support tool.

ONE HOUR, not thirty days. a session lasts a month because a person uses their
own account daily; impersonation is a task you finish. an hour is long enough to
reproduce a problem and short enough that a forgotten tab stops being a live
credential for somebody else's account by lunchtime.

IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any
token carrying `impersonatedBy`, so impersonating another Super Admin does NOT
hand user management back — which would have made this a way to launder owner
actions through somebody else's id. stated twice, in the gate and in the handler,
because one file away is too far for this one.

THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an
owner session — a far more dangerous thing to own than this whole feature. the
cost is that clearing browser storage strands you as the other person until the
hour expires, and signing in again fixes that.

the banner is loud and sits ABOVE the header, outside everything a fullscreen
panel can hide. the failure it prevents is not a hole, it is forgetting: reading
a mailbox or deleting something while believing it is yours. it asks the server
who it is rather than decoding the token, so it cannot keep showing a name after
the hour has run out.

verified end to end against a throwaway account, since this host has one user:
impersonate 200 with the target's id and impersonatedBy naming the owner, 60
minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining
403, user management while impersonating 403; one [SECURITY] line per start. the
probe account was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 21:51:20 +01:00
pastilhasandClaude Opus 5 02ad4d3bdf deploy/2026.08.27.02 — seven hosts, and a push that lies about applying changes
Every target host is at the tag. The one destructive step ran on six of them and
was guarded by a count each time; alpha kept its table and its 8 rows.

The finding is that `bun db:push` prints "Changes applied" unconditionally — it
said so on an unchanged database, twice in a row. `--verbose` shows the truth:
what it re-applies everywhere is pre-existing composite-key drift, and nothing
touches dav_app_passwords on any host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 20:38:15 +01:00
pastilhasandClaude Opus 5 91147b403b deploy/2026.08.27.02 — a plan with commands, and a schema claim that is checked
2026.08.27.01 was REFUSED before a single host was contacted, and the commander
was right twice over. its plan described the release instead of instructing the
rollout — no step list at all, where deploy/README.md defines a plan as "what to
run, in order, on which hosts" and steps 4 and 5 of the procedure are plan-driven.
and it asserted "nothing here changes a schema" on the day dav/schema.ts was
deleted out of core.

that second one was not a formatting mistake. dav_app_passwords now lives in
plugins/caldav, and gen-plugin-schemas follows plugin DIRECTORIES — so on a host
without that directory the table is in the database and not in the schema, and
`db:push` proposes dropping it. on alpha that is eight device credentials.

so this plan answers it from a survey rather than a guess. alpha has the plugin
and 8 rows; the other six have no plugin and an empty leftover table; zero hosts
run officer-marketplace, which retires the `tags` column question entirely. the
orphan table is dropped EXPLICITLY, guarded by a count, and skipped on alpha —
never as a db:push side effect.

step 6 is new and would have been missed: officer-opencode has to restart or none
of the skills, agent-identity or credential work in this release does anything.

ALSO THE SIGN-OUT. a single 401 from any endpoint wiped the token from both
storages and reloaded — which signs the owner out of every tab and keeps them
there, since nothing is left to recover from. that is the right response to a
revoked session and far too violent a response to one endpoint answering 401 for
its own reasons. it now confirms against /auth/me first, and treats an unreachable
server as emphatically not a revocation: rebooting the box must not end the
session. reported mid-demo, with nothing in the logs and a freshly signed token
working against every route the app loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 20:27:11 +01:00
12 changed files with 523 additions and 3 deletions
+78
View File
@@ -0,0 +1,78 @@
# 2026.08.27.02
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
claimed and undeployed; do not try to revive it.
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
## The schema question, answered per host
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
Surveyed 2026-08-28, every host:
| Host | `plugins/caldav/` | `dav_app_passwords` |
| --- | --- | --- |
| `alpha` | present | **8 rows** |
| `fofos` | absent | present, 0 rows |
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
| `macbruno` | absent | present, 0 rows |
| `production-pertento-bridge` | absent | present, 0 rows |
| `edge-pertento-bridge` | absent | present, 0 rows |
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
holds anything:
```
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
# anything else → STOP. Report the count and do not continue on this host.
```
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
4. `bun run plugins:update`
5. `pm2 restart officer`
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
nothing about OpenCode changes.
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
normally and must report no changes.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
it, and it is the owner's call.
## Risk
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
count and it is skipped on the one host where the table has contents.
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
device registration is worth one check (it forwards correctly on `officer-dev`).
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout.
+96
View File
@@ -0,0 +1,96 @@
# 2026.08.27.02 — report
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
was dirty in tracked files, none diverged, none needed a skip.
## Per host
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
| --- | --- | --- | --- | --- | --- |
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
process was left `errored` or `stopped` anywhere.
## The schema, verified by query per host
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
not inferred from an exit code. Final sweep:
```
alpha caldav=yes dav_app_passwords=present rows=8
fofos caldav=no dav_app_passwords=GONE
macbookpro caldav=no dav_app_passwords=GONE
macbruno caldav=no dav_app_passwords=GONE
production-pertento-bridge caldav=no dav_app_passwords=GONE
edge-pertento-bridge caldav=no dav_app_passwords=GONE
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
```
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
are intact.
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
under the plan's own guard rather than under an assumption.
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
`officer-marketplace`, confirmed zero of seven.
## `bun db:push` prints `Changes applied` even when it changes nothing
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
unchanged database. It is unconditional output, not evidence.
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
real content was:
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
`pk_music_now_playing` as the same two-column key
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
said it would.
Not fixed here. A rollout is the wrong place for a schema-code change.
## Plugins
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
deadpool 2/2, macbookpro none installed.
## What was not done
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
owner restarts it deliberately.
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
knowing.
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
touch it and it was left alone.
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
## Worth a look, per the plan's own risk note
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
router's mount order changed (device registration is worth one check).
+71
View File
@@ -0,0 +1,71 @@
# 2026.08.28.01
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
Two commits since `2026.08.27.02`, one of which is that release's own report.
## No schema change. No dependency change. No plugin change.
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
plan got wrong, so it is stated here as a check that was run rather than a belief.
## `bun db:push` is deliberately NOT a step
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
mis-diff in `src/databases/CLAUDE.md`.
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
them across the fleet yesterday.
## Every host
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
2. `pm2 restart officer`
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
with a note.
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
boot.
## Verify, per host
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
401 or 403 means mounted, 404 means the bundle did not move:
```
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
```
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
## Risk
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
impersonated — that last one is what stops it chaining into owner powers.
Nothing to roll back but the merge: no data is written and no schema is touched.
## Hosts
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
deadpool-rezio-bridge
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "officer",
"version": "2026.08.27.01",
"version": "2026.08.28.01",
"private": true,
"type": "module",
"workspaces": [
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
<ImpersonationBanner />
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
@@ -1,7 +1,17 @@
import { startImpersonation } from 'officerdev';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import {
Copy,
Crown,
KeyRound,
Loader2,
RotateCcw,
SquareTerminal as TerminalIcon,
Trash2,
UserCog,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -128,6 +138,25 @@ export const UsersSection = () => {
}
};
/**
* Sign in as this person, for an hour.
*
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
* new password and a deletion, which are neither.
*/
const impersonate = async (user: ManagedUser) => {
setPendingId(user.id);
try {
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
// Swaps the token and reloads — nothing after this line runs.
startImpersonation(token);
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -263,6 +292,22 @@ export const UsersSection = () => {
</Button>
)}
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
offers it would be the one row where it cannot work. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Sign in as ${user.email}`}
title="Sign in as this user for one hour"
onClick={() => void impersonate(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
+70
View File
@@ -0,0 +1,70 @@
import type { Handler } from 'hono';
import { getUserById } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { clientIp } from '../auth/client-ip';
/**
* Sign in AS another account, from user management.
*
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
* password reset means locking the person out of their own account to look at it, which is worse than
* the bug.
*
* ── The token says whose it is, and who is really holding it ──
*
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
* forge activity as somebody else is a different and much worse thing.
*
* ── One hour, not thirty days ──
*
* A session token lasts a month because a person uses their own account daily. Impersonation is a task
* you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops
* being a live credential for somebody else's account by lunchtime.
*
* ── What it cannot do ──
*
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
* owner actions through somebody else's id.
*
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
* deleted or non-Active user, exactly as it does for an ordinary session.
*
* ── What it deliberately does NOT do ──
*
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
* be stranded as somebody else.
*/
export const impersonateUserHandler: Handler = async (ctx) => {
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
const targetId = Number(ctx.req.param('id'));
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
const target = await getUserById(targetId);
if (!target) throw errors.NOT_FOUND('No such user');
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
const { id, email, name, username } = target;
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
console.warn(
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
);
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
};
+14
View File
@@ -9,6 +9,7 @@ import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
import { permissionAdminRouter } from './permissions-routes';
import { impersonateUserHandler } from './impersonate';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -23,6 +24,17 @@ usersRouter.put('/', updateUserHandler);
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
/*
An impersonated session is not the owner, whoever it is wearing.
`isSuperAdmin` reads the id on the token, so impersonating another Super Admin would otherwise hand
user management straight back — and every action taken there would be recorded against the person
impersonated rather than the person doing it. This one line is what stops impersonation being a way
to launder owner actions through somebody else's account, and it is also what stops it chaining.
*/
if ((ctx.get('user') as { impersonatedBy?: unknown }).impersonatedBy) {
throw errors.FORBIDDEN('An impersonated session cannot manage users');
}
return next();
};
@@ -36,6 +48,8 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Sign in AS somebody, for an hour, on a token that says who is really holding it. See impersonate.ts.
usersRouter.post('/:id/impersonate', ownerGate, impersonateUserHandler);
// Which permissions each role holds. Owner-gated inside its own router.
usersRouter.route('/', permissionAdminRouter);
+39
View File
@@ -206,6 +206,45 @@ const handleRevokedSession = (url: string) => {
if (revocationHandled) return;
revocationHandled = true;
/*
Guard 2b: CONFIRM the session is dead before destroying it.
A 401 from any one endpoint used to be enough to wipe the token out of both storages and reload —
which signs the owner out of every tab, and keeps them signed out, because there is then no token
anywhere to recover from. That is the right response to a revoked session and far too violent a
response to one endpoint answering 401 for its own reasons.
Reported 2026-08-27, mid-demo: every refresh and every new tab came back signed out, with nothing in
the logs and a freshly signed token working fine against every route the app loads. One transient
401 is indistinguishable from a revocation at the call site — so ask the one endpoint whose whole
job is to answer it.
`/auth/me` is exempt from this handler (the `/auth/` guard above), so this cannot recurse.
*/
void (async () => {
try {
// Derived from the URL that just failed, because the base is passed per call rather than held in
// config — a relative guess would be wrong on any host that mounts the API elsewhere.
const probe = /\/api\//.test(url) ? url.replace(/\/api\/.*$/, '/api/auth/me') : '/api/auth/me';
const res = await fetch(probe, { headers: { Authorization: `Bearer ${theToken}` } });
// Still good: that 401 belonged to one endpoint, not to the session. Leave everything alone and
// let the next revocation try again.
if (res.ok) {
revocationHandled = false;
return;
}
} catch {
// The server is unreachable — a restart, a dropped network. Emphatically not a revocation, and
// wiping the session because the box was rebooting is the failure this guard exists to stop.
revocationHandled = false;
return;
}
destroySession();
})();
};
/** Actually end it: forget the token everywhere a load could read it back, then reload once. */
const destroySession = () => {
theToken = null;
// Every key `createClient` reads, or the next load signs straight back in with the dead token.
for (const key of ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN']) {
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { UserCog, LogOut } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { isImpersonating, endImpersonation } from './impersonation';
/**
* A bar across the top, for as long as you are somebody else.
*
* Loud on purpose. The failure this prevents is not a security hole, it is a person forgetting — reading
* a mailbox, sending a message or deleting something believing it is theirs. Every action taken here is
* recorded against the account being worn, so the only defence is that you cannot miss it.
*
* It asks the server who it is rather than decoding the token: the token is the thing being tested, and a
* banner that reads its own claims would keep showing a name after the hour ran out.
*/
export const ImpersonationBanner = () => {
const client = useClient();
const active = isImpersonating();
const { data } = useQuery({
queryKey: ['auth', 'me', 'impersonation'],
queryFn: () => client.get<{ email?: string; name?: string }>('/auth/me'),
enabled: active,
staleTime: 60_000,
});
if (!active) return null;
return (
<div className="flex shrink-0 items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-xs font-medium text-black">
<UserCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
You are signed in as <b>{data?.name ?? data?.email ?? 'another account'}</b>. Everything you do is
recorded as theirs.
</span>
<button
onClick={endImpersonation}
className="flex shrink-0 items-center gap-1 rounded bg-black/15 px-2 py-0.5 transition-colors hover:bg-black/25"
>
<LogOut className="h-3 w-3" />
Back to yourself
</button>
</div>
);
};
@@ -0,0 +1,55 @@
/**
* Being somebody else, and getting back.
*
* ── Why the way back is a stashed token and not a server route ──
*
* Returning to yourself is putting back the token you already had. There is no server state to expire,
* leak or get wrong, and no route that mints an owner token — which would be a far more dangerous thing
* to own than this whole feature. The cost is that clearing browser storage strands you as the other
* person until their hour runs out; signing in again fixes it, and that is the right trade against a
* route that hands out owner sessions.
*
* ── Why `localStorage` and not `sessionStorage` ──
*
* The same storage the client reads its token from, so the two cannot disagree, and so a second tab
* opened mid-support sees the same account rather than silently being the owner.
*/
const TOKEN_KEYS = ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN'] as const;
/** Where the owner's own token waits. Distinct from every key the client reads, so it is never picked up. */
const STASH_KEY = 'officer:impersonation:owner-token';
const writeToken = (token: string) => {
for (const key of TOKEN_KEYS) localStorage.setItem(key, token);
(window as { officerBearerToken?: string }).officerBearerToken = token;
};
/** True while this browser is wearing somebody else's session. */
export const isImpersonating = (): boolean => Boolean(localStorage.getItem(STASH_KEY));
/**
* Swap to the target's token, keeping your own.
*
* A full reload rather than a re-render: every query in flight, every socket and every cached page
* belongs to the previous account. Refetching them piecemeal is how a support view ends up showing two
* people's data at once.
*/
export const startImpersonation = (token: string) => {
const mine = localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ?? localStorage.getItem('BEARER_TOKEN');
// Refuse rather than stash nothing: without the owner's token there is no way back, and discovering
// that after the swap is the worst possible moment.
if (!mine) throw new Error('Could not find your own session to return to');
// Never overwrite an existing stash — that would be a second hop overwriting the way home.
if (!localStorage.getItem(STASH_KEY)) localStorage.setItem(STASH_KEY, mine);
writeToken(token);
window.location.assign('/');
};
/** Put your own token back and reload. Safe to call when not impersonating: it does nothing. */
export const endImpersonation = () => {
const mine = localStorage.getItem(STASH_KEY);
if (!mine) return;
localStorage.removeItem(STASH_KEY);
writeToken(mine);
window.location.assign('/');
};
+4
View File
@@ -139,3 +139,7 @@ export type { StoreItem as AppStoreItem } from './apps/AppStore';
// docs/localization.md's open question and is not answered by this.
export { panelName } from './locales/panelName';
export { useTranslation } from 'i18n';
// Impersonation: the banner the owner cannot miss, and the token swap behind it. See impersonation.ts.
export { ImpersonationBanner } from './apps/Impersonation/ImpersonationBanner';
export { startImpersonation, endImpersonation, isImpersonating } from './apps/Impersonation/impersonation';