Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8af9d424 | ||
|
|
fb0cc880ac | ||
|
|
02ad4d3bdf | ||
|
|
91147b403b |
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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('/');
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user