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".
76 lines
3.5 KiB
TypeScript
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();
|
|
}
|
|
};
|