mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-23 03:51:14 +00:00
* perf(workers): skip hot-reload lookups for files no worker is bound to
The fs.write.file / fs.remove.node / fs.move.node listeners listed every
workers.puter.* row of the file's owner on every event, for every user.
The event's FSEntry already carries the worker rows bound to it, so a file
with none returns before any I/O; a bound file reads just its own rows,
batched, from the primary.
* perf(apps): read the uid alias only after a direct lookup misses
Every AppDriver uid lookup fired an uncached system-KV GetItem for the
merge alias in parallel with the direct read, even when the direct read
hit. The alias is now read only on a miss.
* refactor(apps): one app view with the launch gates, built by AppService
AppDriver's serializer and its private-app / hosted-backing gates move to
AppService.views, built on a pure toAppView that is the one place
index_url is withheld. AppDriver, the subdomains driver's embedded apps and
the GUI shell view all use it. Visibility of protected apps
(filterReadable) moves with it.
Subdomain embeds (associated_app, app_owner) used to return index_url with
no gate; they now withhold it from a viewer who may not open a private app
or whose hosted backing is gone, and carry privateAccess, godmode and
feedback_enabled like a direct app read.
* refactor(apps): one toAppSummary; batch the recommended and taskbar lookups
SuggestedApps, RecommendedApps, /get-launch-apps and the taskbar each built
their own launch summary; they now share toAppSummary (the extra
feedback_enabled / external flags appear on the lists that lacked them).
getRecommendedApps awaited getByName once per recommended app (29) and
getTaskbarItems did the same per taskbar entry on every whoami; both now
make one batched call per key through the new AppStore.getByNames, which
keeps getByName's case-insensitive and old-name matching. The taskbar's
raw UPDATE of the user row goes through UserStore.update, and
/get-launch-apps checks hosted backings in one batch and reaches the
stores without casts.
* refactor(apps): app routes read through AppService; /delete-site calls the subdomains driver
AppController used the global driversContainers through a getter whose
boot-order comment no longer held; it uses the injected drivers and
AppService. GET /apps/:name looked each name up, read it again through the
driver and resolved private access a second time, emitting
app.privateAccess.resolveLaunch twice per private app; it now batches the
names and resolves once. POST /query/app did a lookup, a driver read and a
raw app_filetype_association SELECT per selector (up to 200, serially); it
now makes one batched lookup per key, one grant check and one filetype read.
The driver's read shape is a pure toAppReadView both use.
HostingController's /delete-site re-implemented the subdomains driver's
delete (protected check, delete, subdomain.delete event); it keeps its
owner-scoped 404 and calls the driver. Hosting and Desktop controllers drop
constructors that discarded the drivers argument, and trivial getters.
* refactor(drivers): one per-method policy validator and resolver; drop @Driver
Four policy blocks (rateLimit, concurrent, requireSubscription,
requireReputation) share the { default, methods } shape but each had its
own validator and resolver. They become validatePerMethod(value, label,
policy) and resolvePerMethod(cfg, method); error messages are unchanged.
The @Driver class decorator and the prototype keys it wrote had no users
outside its own tests; drivers declare their fields, and resolveDriverMeta
reads only those. DriverController takes the driver name from the meta it
already resolved instead of probing the prototype.
resolveDriverMethodRateLimit/Concurrent stay as deprecated aliases for the
email extension.
* refactor(drivers): shared actor guards and crud-q concurrency policy
The apps, subdomains, workers and notifications drivers each carried a
private #requireActor, two carried the same #requireVerified, and apps and
subdomains the same owner/app_owner write predicate. These become
requireContextActor, requireContextUserActor, assertActorEmailVerified and
actorOwnsRow in core/actor.ts; each driver keeps its own error for the
cases that differed (system actors still read apps; notifications still
answer 403 for an actor without a user). The identical crud-q concurrency
block is one CRUD_CONCURRENT constant.
The metering, installedApps and appTelemetry extensions re-checked an actor
their routes already require; those checks and the tests for them go.
* refactor(extensions): store methods instead of raw SQL in OSS extensions
installedApps and appTelemetry queried user_to_app_permissions directly
and metering read apps by name with its own SELECT. PermissionStore gains
listAppsGrantedByUser, listAppAuthenticatedUsers (with the email-share
check folded in) and countAppUsers; metering uses AppStore.getByName.
* refactor(server): one layer builder and one lifecycle hook runner
PuterServer built each of its five layers with its own copy of the same
loop (built-ins, then extension additions, into the layer and its export
container), and ran each lifecycle hook with five near-identical loops per
hook. Both are now one helper each: #instantiateLayer and
#runHook(order, hook). Construction order, the args each layer receives,
and hook order (start and prepare bottom-up, shutdown top-down) are
unchanged.
* fix(workers): tear down a deleted source's worker after its FK nulls root_dir_id
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { UserRow } from '../stores/user/UserStore';
|
|
import { Context } from './context';
|
|
import { HttpError } from './http/HttpError';
|
|
import { assertVerifiedEmail } from './http/verifiedEmail';
|
|
|
|
export interface ActorApp {
|
|
uid: string;
|
|
id?: number;
|
|
}
|
|
|
|
/**
|
|
* Set when the actor acts through an access token issued by `issuer`; the
|
|
* token's `access_token_permissions` rows gate which of the issuer's
|
|
* permissions it may exercise.
|
|
*/
|
|
export interface ActorAccessToken {
|
|
uid: string;
|
|
issuer: Actor;
|
|
authorized?: Actor | null;
|
|
/**
|
|
* Personal access token (signed `full_access` claim): full API reach, but
|
|
* still rejected by `requireUserActor`, so never account management.
|
|
*/
|
|
fullAccess?: boolean;
|
|
/**
|
|
* The privileged app a full-access token was launched for. Attribution
|
|
* only: it never sets `effectiveApp`, so the token keeps the account's
|
|
* reach while revokes, audits and gate hooks can still name the app.
|
|
*/
|
|
godmodeApp?: ActorApp | null;
|
|
}
|
|
|
|
export interface Actor {
|
|
user: Partial<UserRow>;
|
|
/**
|
|
* The app this actor carries _directly_, empty on an access token an app
|
|
* issued. Answers "is this an app acting as itself" — `isAppActor` is
|
|
* usually the clearer way to ask — and never "which app is acting", which
|
|
* reads as "no app" for a token and falls open.
|
|
*/
|
|
app?: ActorApp | null;
|
|
/**
|
|
* The app this actor ultimately acts as: its own `app`, else the app of its
|
|
* access token's issuer. Gates asking "which app" must read this, not
|
|
* `app`. `null` means resolved to no app; `undefined` means the actor
|
|
* skipped `makeActor` and must not be read as "no app".
|
|
*/
|
|
effectiveApp?: ActorApp | null;
|
|
/** True for the system actor; skips metering / quota tracking. */
|
|
system?: boolean;
|
|
accessToken?: ActorAccessToken | null;
|
|
/**
|
|
* Set when authenticated by a session token or an app-under-user token
|
|
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
|
|
*/
|
|
session?: { uid: string; kind?: string | null } | null;
|
|
/**
|
|
* Events handler runs behind this actor's writes; set only from an events
|
|
* handler token or a token one minted. Provenance, not authority, so
|
|
* derived actors keep it.
|
|
*/
|
|
handlerDepth?: number;
|
|
/**
|
|
* When the token behind `handlerDepth` expires, in unix seconds. An app
|
|
* token minted from this actor expires no later.
|
|
*/
|
|
handlerExpiresAt?: number;
|
|
}
|
|
|
|
/** UUID of the baked-in system user (see 0025 seed migration). */
|
|
export const SYSTEM_ACTOR_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
|
|
|
/** The default system actor used when no actor is supplied. */
|
|
export const SYSTEM_ACTOR: Actor = {
|
|
user: { uuid: SYSTEM_ACTOR_UUID, username: 'system' },
|
|
effectiveApp: null,
|
|
system: true,
|
|
};
|
|
|
|
/**
|
|
* Build an actor with `effectiveApp` derived; the issuer is already collapsed,
|
|
* so one hop suffices.
|
|
*/
|
|
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
|
|
...actor,
|
|
effectiveApp: actor.app ?? actor.accessToken?.issuer.effectiveApp ?? null,
|
|
});
|
|
|
|
/**
|
|
* Fail closed on an actor that skipped `makeActor`, at the edge rather than
|
|
* inside a gate reading `undefined` as "no app".
|
|
*/
|
|
export const assertResolvedActor = (actor: Actor): Actor => {
|
|
if (actor.effectiveApp === undefined) {
|
|
throw new Error(
|
|
'actor was built without `makeActor`: `effectiveApp` is unresolved, ' +
|
|
'and app-scoped gates would read that as "no app"',
|
|
);
|
|
}
|
|
return actor;
|
|
};
|
|
|
|
/** An actor acting for a user, as most driver methods require. */
|
|
export type UserActor = Actor & {
|
|
user: { id: number; uuid: string; username: string };
|
|
};
|
|
|
|
const authenticationRequired = () =>
|
|
new HttpError(401, 'Authentication required', {
|
|
legacyCode: 'unauthorized',
|
|
});
|
|
|
|
/** The request's actor; 401 when the request carries none. */
|
|
export const requireContextActor = (): Actor => {
|
|
const actor = Context.get('actor');
|
|
if (!actor) throw authenticationRequired();
|
|
return actor;
|
|
};
|
|
|
|
/** The request's actor, which must act for a user; 401 otherwise. */
|
|
export const requireContextUserActor = (): UserActor => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user?.id) throw authenticationRequired();
|
|
return actor as UserActor;
|
|
};
|
|
|
|
/**
|
|
* The driver-level twin of the HTTP `requireVerified` gate, so `/drivers/call`
|
|
* can't bypass it. Inert unless `strict_email_verification_required` is set:
|
|
* installs without SMTP must not be bricked.
|
|
*/
|
|
export const assertActorEmailVerified = (
|
|
actor: Actor,
|
|
config: { strict_email_verification_required?: unknown },
|
|
): void => {
|
|
assertVerifiedEmail(
|
|
Boolean(config.strict_email_verification_required),
|
|
actor.user,
|
|
400,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Whether `actor` owns a row: its user is the row's owner, and an app actor is
|
|
* also the app that created it. Both halves matter, since `app_owner` alone is
|
|
* shared by every user of that app.
|
|
*/
|
|
export const actorOwnsRow = (
|
|
actor: Actor,
|
|
row: { ownerUserId: unknown; appOwnerId: unknown },
|
|
): boolean => {
|
|
const app = actor.effectiveApp;
|
|
if (app?.id && app.id !== row.appOwnerId) return false;
|
|
return actor.user?.id === row.ownerUserId;
|
|
};
|
|
|
|
export const isSystemActor = (actor: Actor | undefined | null): boolean => {
|
|
return !!actor?.system || actor?.user?.uuid === SYSTEM_ACTOR_UUID;
|
|
};
|
|
|
|
export const isAppActor = (actor: Actor | undefined | null): boolean => {
|
|
return !!actor?.app && !isAccessTokenActor(actor);
|
|
};
|
|
|
|
/**
|
|
* The account acting as itself, through nothing: no app and no token of any
|
|
* kind. Narrower than `isAccountContext`, which also admits a full-access token
|
|
* — use this where the distinction is "a browser session" rather than "the
|
|
* account's own reach".
|
|
*/
|
|
export const isPlainUserActor = (
|
|
actor: Pick<Actor, 'app' | 'accessToken'> | undefined | null,
|
|
): boolean => !!actor && !actor.app && !actor.accessToken;
|
|
|
|
export const isAccessTokenActor = (
|
|
actor: Actor | undefined | null,
|
|
): boolean => {
|
|
return !!actor?.accessToken;
|
|
};
|
|
|
|
/**
|
|
* Whether the actor holds the account's own reach: a plain session or a
|
|
* full-access token. Use this, not `effectiveApp === null`, which would also
|
|
* admit scoped access tokens. Unresolved actors answer no.
|
|
*/
|
|
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
|
|
if (!actor || actor.effectiveApp !== null) return false;
|
|
return !actor.accessToken || actor.accessToken.fullAccess === true;
|
|
};
|
|
|
|
/**
|
|
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
|
|
* cache) and for cycle detection.
|
|
*/
|
|
export const actorUid = (actor: Actor): string => {
|
|
if (actor.accessToken) {
|
|
const authorizedUid = actor.accessToken.authorized
|
|
? actorUid(actor.accessToken.authorized)
|
|
: '<none>';
|
|
return `access-token:${actorUid(actor.accessToken.issuer)}:${authorizedUid}:${actor.accessToken.uid}`;
|
|
}
|
|
if (isSystemActor(actor)) return 'system';
|
|
if (actor.app) return `app-under-user:${actor.user.uuid}:${actor.app.uid}`;
|
|
return `user:${actor.user.uuid}`;
|
|
};
|
|
|
|
/**
|
|
* Return a user-only actor for any app-under-user actor. For non-app actors,
|
|
* returns the actor unchanged.
|
|
*/
|
|
export const userRelatedActor = (actor: Actor): Actor => {
|
|
if (!actor.app && !actor.accessToken) return actor;
|
|
return {
|
|
user: actor.user,
|
|
effectiveApp: null,
|
|
...(actor.handlerDepth ? { handlerDepth: actor.handlerDepth } : {}),
|
|
};
|
|
};
|
|
|
|
/** How many events handler runs led to a write by this actor. */
|
|
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
|
|
actor?.handlerDepth ?? 0;
|