HeyPuter/puter · error · HttpError

forbidden

forbidden

Error message

User actor required

What it means

Thrown by NotificationDriver.#requireUserActor() (NotificationDriver.ts:214) when the request is authenticated but the actor is not a full user — actor.user?.id is falsy. The notification driver is strictly owner-limited (see the permission-model note at lines 43-47): every method (create, read, select, mark_shown, mark_acknowledged) calls this guard first. It exists so non-user principals (raw app actors, the system actor, scoped access tokens without a populated user row) can never read or write a user's notifications.

Source

Thrown at src/backend/drivers/notification/NotificationDriver.ts:214

        const ok = await this.stores.notification.markAcknowledged(
            uid,
            actor.user.id,
        );
        return { success: ok };
    }

    // -- Permissions -------------------------------------------------

    #requireUserActor(): Actor & {
        user: { id: number; uuid: string; username: string };
    } {
        const actor = Context.get('actor') as Actor | undefined;
        if (!actor)
            throw new HttpError(401, 'Authentication required', {
                legacyCode: 'unauthorized',
            });
        if (!actor.user?.id)
            throw new HttpError(403, 'User actor required', {
                legacyCode: 'forbidden',
            });
        // App-under-user actors are not allowed for notifications.
        if (actor.app)
            throw new HttpError(403, 'App actors cannot access notifications', {
                legacyCode: 'forbidden',
            });
        return actor as Actor & {
            user: { id: number; uuid: string; username: string };
        };
    }

    // -- Serialization -----------------------------------------------

    #toClient(
        row: Record<string, unknown> | null,
    ): Record<string, unknown> | null {
        if (!row) return null;

View on GitHub (pinned to 908ec23eda)

Solutions

  1. If the caller is server-internal (another service pushing a notification), bypass the driver and call NotificationStore.create({ userId, value }) directly with the target user's id — this is the documented path in the class JSDoc.
  2. If the caller must go through the driver, authenticate with a real user session token so AuthService runs makeActor with a full user row (user.id populated), not a system/app/access-token actor.
  3. Verify the auth middleware on the route actually resolves a user principal; an actor with user = {} or user = { uuid, username } (no id) means the user row was never loaded — fix the loader so the numeric id is present.
  4. For access-token actors, ensure the token is authorized by a user and that AuthService copies that user's full row (including id) onto actor.user rather than leaving a partial.
  5. In tests, build the actor through makeActor with a complete user row (id, uuid, username) — mirroring NotificationDriver's own return-type contract — instead of hand-writing a partial actor literal.

Example fix

// before — server-internal push goes through the driver with a system actor
await driver.call('puter-notifications', 'create', {
  object: { value: { text: 'hi' } },
}); // throws 403 'User actor required'

// after — write straight to the store for server-internal push
await services.get('notification-store').create({
  userId: targetUser.id,
  value: { text: 'hi' },
});

// or, if calling the driver, ensure a real user actor is on the context
// (auth middleware must populate actor.user.id before this runs)
Defensive patterns

Strategy: validation

Validate before calling

// Server-internal caller: check the actor before touching the driver.
// If it isn't a real user, route to the store directly.
import type { Actor } from '../../core/actor.js';

function resolveNotificationTarget(actor: Actor | undefined) {
  if (actor?.user?.id) return { via: 'driver' as const, actor };
  // not a user actor — do NOT call the driver; use the store with an explicit userId
  return { via: 'store' as const, userId: undefined };
}

Type guard

import type { Actor } from '../../core/actor.js';

type UserActor = Actor & {
  user: { id: number; uuid: string; username: string };
};

// Mirrors NotificationDriver.#requireUserActor's admission rules
// (must have user.id AND no app — see NotificationDriver.ts:209-216).
function isUserActor(a: Actor | undefined): a is UserActor {
  return !!a && typeof a.user?.id === 'number' && !a.app;
}

Try / catch

// Only when you cannot validate ahead of the call.
import { HttpError } from '../../core/http/HttpError.js';

try {
  await driver.select({ predicate: 'unseen' });
} catch (e) {
  if (e instanceof HttpError && e.status === 403 &&
      e.message === 'User actor required') {
    // not a user principal — fall back to a direct store write
    // or surface 'sign in as a user' to the caller; do not retry unchanged.
  } else {
    throw e;
  }
}

Prevention

When it happens

Trigger: Calling any puter-notifications driver method via /drivers/call (interface 'puter-notifications') with: (a) a raw app token where the app authenticated on its own and actor.user has no numeric id; (b) the system actor SYSTEM_ACTOR (actor.ts:88-92) which only carries user.uuid + user.username, no id; (c) a scoped access-token actor whose authorized user row was never hydrated with id; (d) AuthService built an actor literal that skipped makeActor so user is a partial. Any of these reaching create/read/select/mark_shown/mark_acknowledged trips the 403.

Common situations: A backend service pushes a notification through the driver with a system/app token instead of writing to NotificationStore directly (the class doc at lines 39-41 says server-internal push must go through the store, not the driver). A CLI/worker script reuses an app token expecting it to act 'as the user'. A test harness that constructs a minimal actor without a full user row. A version where the auth middleware stopped hydrating user.id on the actor, or a deployment that authenticates machine-to-machine calls without a user principal.

Related errors


AI-assisted analysis of HeyPuter/puter@908ec23eda (2026-08-12). Data as JSON: /api/errors/44138dd6f8909d27. Report an issue: GitHub.