Files
platform/src/servers/_middlewares/user-middleware.ts
T
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00

76 lines
3.5 KiB
TypeScript

import type { MiddlewareHandler } from 'hono';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isLockdown, noteBlocked } from '../api/auth/panic';
import { getUserById, isTokenBlacklisted } from 'officerdb';
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
if (isLockdown()) {
noteBlocked(`${ctx.req.method} ${ctx.req.path}`);
throw errors.UNAUTHORIZED();
}
const { authorization } = ctx.req.header();
// Support token in query param for media elements (<audio>, <video>, <img>)
// that cannot send Authorization headers
let token: string | undefined;
if (authorization) {
[, token] = authorization.split(' ');
} else {
token = ctx.req.query('token') || undefined;
}
if (!token) throw errors.UNAUTHORIZED();
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
// permission gate in hono.ts confines a non-owner to what their role grants.
try {
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
// an API key's revocation is a column checked during that lookup, so there is no blacklist to consult
// and no issued-at to compare. A password change deliberately does NOT kill a user's API keys; they
// are the credential you rotate independently, which is the whole reason they exist.
const user = await resolveAuthToken(token);
if (!user) throw errors.UNAUTHORIZED();
if (user.via === 'jwt') {
// Check if token is blacklisted (explicit signout)
if (user.jti) {
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
}
// The account still has to exist, and still has to be allowed in.
//
// This lookup used to happen only for the password-change comparison below, and its result was read
// as `dbUser?.passwordChangedAt` — so a DELETED account fell straight through the optional chain and
// kept working on a token that is still cryptographically valid, for up to the full 30 days. Observed
// 2026-08-11: an account deleted from the dashboard survived a page refresh in another window.
//
// `status` is the same shape of hole. signin.ts refuses anything that is not 'Active', but nothing
// re-checked it afterwards, so marking someone Blocked or Banned did not end the session they already
// had — which is precisely when you would be doing it.
//
// Re-read per request rather than trusted as a claim, for the reason the role is not a claim either:
// a revocation has to take effect on the next request, not at next sign-in.
if (user.id) {
const dbUser = await getUserById(user.id);
if (!dbUser) throw errors.UNAUTHORIZED();
if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();
// Token issued before the password changed → refuse. iat is seconds, passwordChangedAt is a Date.
if (user.iat && dbUser.passwordChangedAt && user.iat * 1000 < dbUser.passwordChangedAt.getTime()) {
throw errors.UNAUTHORIZED();
}
}
}
ctx.set('user', user);
return next();
} catch (ex) {
if (ex instanceof errors.CustomError) throw ex;
throw errors.UNAUTHORIZED();
}
};