the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
import { sign } from '@@/jwt';
|
|
import type { CredentialScope } from '@@/auth-token';
|
|
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
|
|
|
const { PUBLIC_URL } = process.env;
|
|
|
|
const PUBLIC_HOST = (() => {
|
|
try {
|
|
return PUBLIC_URL ? new URL(PUBLIC_URL).host : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
})();
|
|
|
|
type TaskApiUser = {
|
|
id: number;
|
|
email: string;
|
|
username?: string | null;
|
|
/**
|
|
* The narrowing carried by the credential that ASKED for this run, or null/absent for a full-authority
|
|
* caller (a browser session, the context menu, a job started by hand).
|
|
*
|
|
* It has to propagate or the narrowing is decorative. A commander holding a key limited to `tasks` can
|
|
* write a task and run it; if the token handed to that script were unnarrowed, the script would come
|
|
* back through the API with the owner's full authority and the limit would have bought nothing. The
|
|
* script gets exactly what its caller had — never more.
|
|
*/
|
|
scope?: CredentialScope | null;
|
|
};
|
|
|
|
// Environment that lets a script task call Officer's own API instead of reimplementing server-side
|
|
// work (transcription, for one — `plugins/transcribe/tasks/transcribe-audio-file/index.ts` uses exactly
|
|
// this to reach the transcribe plugin's own route rather than talking to Whisper itself).
|
|
//
|
|
// Requests go to 127.0.0.1 so a task never depends on DNS or the reverse proxy being up. Origin
|
|
// validation matches the Host header against PUBLIC_URL though, so a bare localhost request is
|
|
// rejected — hence OFFICER_API_HOST, which scripts must pass as an explicit Host header:
|
|
//
|
|
// curl -H "Authorization: Bearer $OFFICER_AUTH_TOKEN" -H "Host: $OFFICER_API_HOST" \
|
|
// "$OFFICER_API_URL/api/transcribe/transcribe"
|
|
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
|
|
const token = await sign(
|
|
{
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username ?? '',
|
|
// Only when narrowed. An absent claim is what every ordinary run already signs, and `resolveAuthToken`
|
|
// spreads the payload onto the identity, so this reaches both doors with no further wiring.
|
|
...(user.scope ? { scope: user.scope } : {}),
|
|
},
|
|
'12h',
|
|
);
|
|
return {
|
|
OFFICER_API_URL,
|
|
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
|
|
OFFICER_AUTH_TOKEN: token,
|
|
};
|
|
}
|