mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 07:10:05 +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
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { Context } from '@heyputer/backend/src/core';
|
|
import { HttpError } from '@heyputer/backend/src/core/http';
|
|
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
|
import type {
|
|
DriverConcurrentConfig,
|
|
DriverRateLimitConfig,
|
|
} from '@heyputer/backend/src/drivers/meta';
|
|
import {
|
|
DEFAULT_FREE_SUBSCRIPTION,
|
|
DEFAULT_TEMP_SUBSCRIPTION,
|
|
} from '@heyputer/backend/src/services/metering/consts';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
|
|
// App-telemetry lets an app owner enumerate the users who have
|
|
// authenticated into their app. v1 shipped this as a driver on the
|
|
// `app-telemetry` interface (methods `get_users` / `user_count`) and
|
|
// puter-js's `puter.apps(...).getUsers()` still calls it that way
|
|
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
|
|
// This is the v2 port of that driver — same interface/method/return shapes
|
|
// so existing puter-js callers work unchanged.
|
|
|
|
const DEFAULT_LIMIT = 100;
|
|
const MAX_LIMIT = 1000;
|
|
const MAX_OFFSET = 100_000;
|
|
|
|
const parseIntParam = (
|
|
value: unknown,
|
|
{
|
|
key,
|
|
min,
|
|
max,
|
|
fallback,
|
|
}: { key: string; min: number; max: number; fallback: number },
|
|
): number => {
|
|
if (value === undefined || value === null) return fallback;
|
|
const parsed =
|
|
typeof value === 'number'
|
|
? value
|
|
: typeof value === 'string' && value.trim() !== ''
|
|
? Number(value)
|
|
: NaN;
|
|
if (
|
|
!Number.isFinite(parsed) ||
|
|
!Number.isInteger(parsed) ||
|
|
parsed < min ||
|
|
parsed > max
|
|
) {
|
|
throw new HttpError(
|
|
400,
|
|
`${key} must be an integer between ${min} and ${max}`,
|
|
);
|
|
}
|
|
return parsed;
|
|
};
|
|
|
|
/**
|
|
* Driver exposing the `app-telemetry` interface.
|
|
*
|
|
* The `/drivers/call` permission gate checks
|
|
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds via
|
|
* the blanket `service` grant (hardcoded-permissions.js +
|
|
* `default_implicit_user_app_permissions`). The real authorization — "is the
|
|
* caller the app owner?" — is enforced inside `get_users` below, exactly as v1
|
|
* did.
|
|
*/
|
|
export class AppTelemetryDriver extends PuterDriver {
|
|
readonly driverInterface = 'app-telemetry';
|
|
readonly driverName = 'app-telemetry';
|
|
readonly isDefault = true;
|
|
|
|
// Declaring nothing here would leave both methods on the generic
|
|
// 600/minute driver default, which does not fit a paginated scan that
|
|
// can ask for MAX_LIMIT rows at MAX_OFFSET. This is a dashboard read —
|
|
// nobody calls it in a loop.
|
|
readonly rateLimit: DriverRateLimitConfig = {
|
|
default: {
|
|
limit: 60,
|
|
window: 60_000,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 30,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 10,
|
|
},
|
|
},
|
|
};
|
|
|
|
readonly concurrent: DriverConcurrentConfig = {
|
|
default: {
|
|
limit: 5,
|
|
bySubscription: {
|
|
[DEFAULT_FREE_SUBSCRIPTION]: 2,
|
|
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
|
|
},
|
|
},
|
|
};
|
|
|
|
/** Users who have authenticated into the given app (owner-only). */
|
|
async get_users({
|
|
app_uuid,
|
|
limit,
|
|
offset,
|
|
}: {
|
|
app_uuid?: string;
|
|
limit?: unknown;
|
|
offset?: unknown;
|
|
} = {}): Promise<
|
|
Array<{ user: string; user_uuid: string; user_email?: string | null }>
|
|
> {
|
|
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
|
|
|
const safeLimit = parseIntParam(limit, {
|
|
key: 'limit',
|
|
min: 1,
|
|
max: MAX_LIMIT,
|
|
fallback: DEFAULT_LIMIT,
|
|
});
|
|
const safeOffset = parseIntParam(offset, {
|
|
key: 'offset',
|
|
min: 0,
|
|
max: MAX_OFFSET,
|
|
fallback: 0,
|
|
});
|
|
|
|
const app = await this.stores.app.getByUid(app_uuid);
|
|
if (!app) throw new HttpError(404, 'App not found');
|
|
|
|
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
|
|
// UUID, not the numeric id. Look up the owner explicitly — the raw
|
|
// app row only carries `owner_user_id`. (v1 got the owner for free
|
|
// because its entity-storage layer eager-joined the owner row.)
|
|
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
|
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
|
const owner = await this.stores.user.getById(ownerId);
|
|
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
|
|
|
// `/drivers/call` requires auth, so there is always an actor.
|
|
const actor = Context.get('actor')!;
|
|
let ownsApp = false;
|
|
try {
|
|
ownsApp = await this.services.permission.check(
|
|
actor,
|
|
`apps-of-user:${owner.uuid}:write`,
|
|
);
|
|
} catch {
|
|
// A failed check is a denial.
|
|
}
|
|
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
|
|
|
// An email surfaces only for a user who granted *this app*
|
|
// `user:<their-uuid>:email:read` — the grant `puter.perms.requestEmail()`
|
|
// obtains and `whoami` honours. Authenticating alone doesn't share it.
|
|
const users = await this.stores.permission.listAppAuthenticatedUsers(
|
|
(app as { id: number }).id,
|
|
{ limit: safeLimit, offset: safeOffset },
|
|
);
|
|
return users.map((u) =>
|
|
u.emailShared
|
|
? { user: u.username, user_uuid: u.uuid, user_email: u.email }
|
|
: { user: u.username, user_uuid: u.uuid },
|
|
);
|
|
}
|
|
|
|
/** Count of users who have authenticated into the given app. */
|
|
async user_count({
|
|
app_uuid,
|
|
}: { app_uuid?: string } = {}): Promise<number> {
|
|
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
|
|
|
const app = await this.stores.app.getByUid(app_uuid);
|
|
if (!app) throw new HttpError(404, 'App not found');
|
|
|
|
return this.stores.permission.countAppUsers((app as { id: number }).id);
|
|
}
|
|
}
|
|
|
|
extension.registerDriver('appTelemetry', AppTelemetryDriver);
|