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
- 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.
- 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.
- 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.
- 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.
- 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
- For server-internal notification push, always go through NotificationStore, never the driver — it's the documented path and side-steps the user-actor guard entirely.
- Build every request-path actor with makeActor and a complete user row (id, uuid, username); never hand-write a partial actor literal for a route that reaches NotificationDriver.
- In tests, assert the actor satisfies isUserActor before calling any notification driver method, so a fixture regression fails loudly in the test instead of as a 403 in CI.
- Treat the legacyCode 'forbidden' + message 'User actor required' as a wiring signal, not a transient error: a retry with the same token will fail identically — fix the principal, don't retry.
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.