toeverything/AFFiNE · warning · BadRequest

bad_request

bad_request

Error message

User feature ${unsupported.join(', ')} is not configurable

What it means

The updateUserFeatures admin mutation only accepts features present in AvailableUserFeatureConfig.configurableUserFeatures(), which currently contains exactly {Feature.Admin}. Any requested feature outside that set makes the resolver throw BadRequest listing the unsupported names. Features in the list but absent from the request are removed (difference + delete), so this is the gate for the whole add/remove flow.

Solutions

  1. Restrict the request to the configurable set — today that is only Feature.Admin
  2. Expose/consult configurableUserFeatures (or hardcode per backend version) in the admin UI so the picker only offers valid values
  3. Re-sync the admin frontend with the backend release before granting features
  4. On error, read the unsupported list from the message to see exactly which names were rejected

Example fix

// before
await updateUserFeatures(userId, ['earlyAccess', 'admin']);

// after
const CONFIGURABLE = new Set([Feature.Admin]); // mirror of backend configurableUserFeatures()
const features = requested.filter(f => CONFIGURABLE.has(f));
if (requested.length !== features.length) throw new Error('non-configurable feature requested');
await updateUserFeatures(userId, features);
Defensive patterns

Strategy: validation

Validate before calling

const CONFIGURABLE = new Set<UserFeatureName>(['Admin']); // mirror configurableUserFeatures()
const invalid = features.filter(f => !CONFIGURABLE.has(f));
if (invalid.length) throw new Error(`not configurable: ${invalid.join(', ')}`);
await updateUserFeatures(userId, features);

Type guard

function isFeatureNotConfigurable(e: unknown): boolean {
  const err = e as { extensions?: { code?: string }; message?: string };
  return err.extensions?.code === 'bad_request' && /not configurable/.test(err.message ?? '');
}

Try / catch

try {
  await updateUserFeatures(userId, features);
} catch (e) {
  if (isFeatureNotConfigurable(e)) {
    return adminToast('Only Admin is admin-configurable in this version');
  }
  throw e;
}

Prevention

When it happens

Trigger: Sending features like 'earlyAccess' or 'copilot' via updateUserFeatures; an older admin UI sending feature names that are no longer admin-configurable; passing the full available-features list instead of the configurable subset.

Common situations: Frontend/backend version skew after the configurable set was narrowed; operators assuming every visible feature flag can be toggled per-user; automated scripts enumerating all Feature enum values.

Understand the failure class

Background: Invalid enum value errors: "Unknown type", "Invalid scope", "must be one of" — when a string is not on the library's allowed list — this error's family across 23 libraries.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/fd21f9f340128e89. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/features/resolver.ts:68

    private readonly event: EventBus
  ) {
    super();
  }

  @Mutation(() => [Feature], {
    description: 'update user enabled feature',
  })
  async updateUserFeatures(
    @Args('id') id: string,
    @Args({ name: 'features', type: () => [Feature] })
    features: UserFeatureName[]
  ) {
    const configurableUserFeatures = this.configurableUserFeatures();
    const unsupported = features.filter(
      feature => !configurableUserFeatures.has(feature)
    );
    if (unsupported.length) {
      throw new BadRequest(
        `User feature ${unsupported.join(', ')} is not configurable`
      );
    }
    const removed = difference(Array.from(configurableUserFeatures), features);

    await Promise.all(
      features.map(feature =>
        this.models.userFeature.add(id, feature, 'admin panel')
      )
    );

    await Promise.all(
      removed.map(feature => this.models.userFeature.remove(id, feature))
    );

    const user = await this.models.user.get(id);
    if (user) {
      this.event.emit('user.updated', user);

View on GitHub (pinned to b4c8548c09)