toeverything/AFFiNE · error · ActionForbidden
action_forbidden
action_forbidden
Error message
You are not allowed to perform this action.
What it means
Thrown inside SigningKeyService.delete's mutate callback when the target key exists but is not eligible for deletion: its status is not 'retiring', OR it has no verifyUntil, OR verifyUntil is still in the future (new Date(key.verifyUntil) >= now). Category 'action_forbidden', code 'action_forbidden'. Only retired keys past their verify-until cutoff may be purged; active or still-verifying keys must be kept so outstanding tokens remain validatable.
Source
Thrown at packages/backend/server/src/core/auth/signing-key.ts:192
async delete(actorId: string, keyId: string) {
const now = new Date();
const updated = await this.models.appConfig.mutate(
SIGNING_KEY_STORE_ID,
actorId,
value => {
const current = this.parse(value);
const key = current.find(key => key.id === keyId);
if (!key) {
throw new InvalidAppConfigInput({
message: 'Signing key does not exist.',
});
}
if (
key.status !== 'retiring' ||
!key.verifyUntil ||
new Date(key.verifyUntil) >= now
) {
throw new ActionForbidden();
}
return current.filter(key => key.id !== keyId);
}
);
this.applyPersisted(updated.value);
this.event.emit('auth.signing_key.deleted', { actorId, keyId });
this.event.broadcast('auth.signing_keys.changed', {});
return this.snapshotMetadata();
}
private applyPersisted(value: unknown) {
const persisted = this.parse(value);
this.replaceSnapshot(persisted);
}
private replaceSnapshot(keys: unknown) {
const persisted = this.parse(keys);
this.snapshot = persisted.map(key => {View on GitHub (pinned to 26c515e050)
Solutions
- First rotate/retire the key so it enters 'retiring' status with a verifyUntil, then wait until now > verifyUntil before deleting.
- Confirm the key status === 'retiring' and verifyUntil is set and in the past before issuing delete.
- Schedule the cleanup (e.g. a delayed job) to run after verifyUntil rather than deleting manually.
Example fix
// before: delete immediately after retire
await signingKey.retire(keyId);
await signingKey.delete(actorId, keyId);
// after: delete only after the verify-until cutoff
const key = await getKey(keyId);
if (key.status !== 'retiring' || !key.verifyUntil || Date.now() < +new Date(key.verifyUntil)) {
throw new Error('Key still within verify window; cannot delete yet');
}
await signingKey.delete(actorId, keyId); Defensive patterns
Strategy: validation
Validate before calling
const key = (await signingKey.snapshotMetadata()).keys.find(k => k.id === keyId);
if (!key) throw new InvalidAppConfigInput({ message: 'Signing key does not exist.' });
const now = Date.now();
const deletable = key.status === 'retiring' && !!key.verifyUntil && now > +new Date(key.verifyUntil);
if (!deletable) throw new Error('Key is active or still within its verify window; cannot delete.');
await signingKey.delete(actorId, keyId); Type guard
function isDeletable(k: { status: string; verifyUntil?: string }): boolean {
return k.status === 'retiring' && !!k.verifyUntil && Date.now() > +new Date(k.verifyUntil);
} Prevention
- Only attempt delete after verifyUntil has elapsed (accessTokenTtl + clock skew).
- Retire (rotate) a key before deleting; never delete an active key.
- Schedule a delayed cleanup job rather than deleting immediately after retire.
When it happens
Trigger: Calling delete(actorId, keyId) (signing-key.ts:187-194) for a key that is 'active', already deleted/unknown status, or 'retiring' but whose verifyUntil cutoff has not yet elapsed (tokens signed by it may still be within their access-token TTL + clock skew).
Common situations: Trying to immediately delete a key right after rotating it, before the verify-until window (accessTokenTtl + CLOCK_SKEW_SECONDS) expires; an admin expecting 'retire' to mean 'instantly deletable'; attempting to delete the currently-active key.
Related errors
- action_forbidden
- invalid_app_config_input
- Blob engine is not initialized
- DatabaseBlockError
- ErrorCode.ValueNotExists
AI-assisted analysis of toeverything/AFFiNE@26c515e050 (2026-08-12).
Data as JSON: /api/errors/e660a7edf2d51906.
Report an issue: GitHub.