mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-15 00:00:04 +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
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
import { Context } from '@heyputer/backend/src/core';
|
|
import { HttpError } from '@heyputer/backend/src/core/http';
|
|
import {
|
|
controllersContainers,
|
|
driversContainers,
|
|
servicesContainers,
|
|
} from '@heyputer/backend/src/exports';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
import { GLOBAL_APP_KEY } from '@heyputer/backend/src/services/metering/consts';
|
|
import {
|
|
creditMultiplierFrom,
|
|
toCredits,
|
|
} from '@heyputer/backend/src/services/metering/utils';
|
|
import type { Request, Response } from 'express';
|
|
|
|
const services = extension.import('service');
|
|
const stores = extension.import('store');
|
|
|
|
// Cached on first request — the underlying cost catalogues are baked into
|
|
// driver/controller source so they only change on deploy.
|
|
let cachedAllCosts: Record<string, unknown>[] | null = null;
|
|
|
|
function collectAllCosts(): Record<string, unknown>[] {
|
|
const all: Record<string, unknown>[] = [];
|
|
const collect = (
|
|
source: Record<string, unknown>,
|
|
kind: 'driver' | 'controller' | 'service',
|
|
) => {
|
|
for (const [name, instance] of Object.entries(source)) {
|
|
const fn = (
|
|
instance as {
|
|
getReportedCosts?: () => Record<string, unknown>[];
|
|
}
|
|
)?.getReportedCosts;
|
|
if (typeof fn !== 'function') continue;
|
|
try {
|
|
const entries = fn.call(instance);
|
|
if (!Array.isArray(entries)) continue;
|
|
for (const entry of entries) {
|
|
all.push({ ...entry, registry: kind, registryKey: name });
|
|
}
|
|
} catch (e) {
|
|
console.warn(
|
|
`[metering] getReportedCosts failed for ${kind}:${name}:`,
|
|
(e as Error).message,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
collect(driversContainers as Record<string, unknown>, 'driver');
|
|
collect(controllersContainers as Record<string, unknown>, 'controller');
|
|
// Services report the costs that aren't tied to one endpoint — egress,
|
|
// which is metered for every response there is.
|
|
collect(servicesContainers as Record<string, unknown>, 'service');
|
|
return all;
|
|
}
|
|
|
|
// -- Credits scaling ---------------------------------------------------
|
|
//
|
|
// When the deployment configures a credit multiplier, every monetary amount
|
|
// is scaled to display credits BEFORE it leaves the server — raw metered
|
|
// amounts are never reported. Counts, units, and byte figures pass through
|
|
// untouched. A response that was scaled says so (`unit: 'credits'`); without
|
|
// a configured multiplier amounts pass through raw and clients render them
|
|
// as dollars.
|
|
|
|
/** The per-usage-type records of a UsageByType map, costs scaled. */
|
|
const scaleUsageByType = (
|
|
usage: Record<string, unknown>,
|
|
multiplier: number,
|
|
): Record<string, unknown> => {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(usage)) {
|
|
// `allowanceUsed` is the month's allowance-charged spend — monetary,
|
|
// scalar, scaled like the total.
|
|
if (key === 'total' || key === 'allowanceUsed') {
|
|
out[key] = toCredits(Number(value) || 0, multiplier);
|
|
} else if (
|
|
value &&
|
|
typeof value === 'object' &&
|
|
typeof (value as { cost?: unknown }).cost === 'number'
|
|
) {
|
|
out[key] = {
|
|
...(value as Record<string, unknown>),
|
|
cost: toCredits((value as { cost: number }).cost, multiplier),
|
|
};
|
|
} else {
|
|
out[key] = value;
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
export const handleMeteringUsage = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
// Behind `requireAuth`, so there is always an actor.
|
|
const actor = Context.get('actor')!;
|
|
|
|
const [actorUsage, allowanceInfo] = await Promise.all([
|
|
services.metering.getActorCurrentMonthUsageDetails(actor),
|
|
services.metering.getAllowedUsage(actor),
|
|
]);
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
if (!multiplier) {
|
|
res.json({ ...actorUsage, allowanceInfo });
|
|
return;
|
|
}
|
|
res.json({
|
|
usage: scaleUsageByType(
|
|
actorUsage.usage as unknown as Record<string, unknown>,
|
|
multiplier,
|
|
),
|
|
appTotals: Object.fromEntries(
|
|
Object.entries(actorUsage.appTotals).map(([appId, totals]) => [
|
|
appId,
|
|
{ ...totals, total: toCredits(totals.total, multiplier) },
|
|
]),
|
|
),
|
|
allowanceInfo: {
|
|
...allowanceInfo,
|
|
remaining: toCredits(allowanceInfo.remaining, multiplier),
|
|
monthUsageAllowance: toCredits(
|
|
allowanceInfo.monthUsageAllowance,
|
|
multiplier,
|
|
),
|
|
// Monetary addon fields scale; absent ones stay absent rather
|
|
// than materializing as NaN/null. purchasedStorage is bytes.
|
|
addons: Object.fromEntries(
|
|
Object.entries(allowanceInfo.addons ?? {}).map(([k, v]) => [
|
|
k,
|
|
(k === 'purchasedCredits' ||
|
|
k === 'consumedPurchaseCredits') &&
|
|
typeof v === 'number'
|
|
? toCredits(v, multiplier)
|
|
: v,
|
|
]),
|
|
),
|
|
unit: 'credits',
|
|
},
|
|
});
|
|
};
|
|
|
|
export const handleMeteringUsageForApp = async (
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
// Behind `requireAuth`, so there is always an actor.
|
|
const actor = Context.get('actor')!;
|
|
|
|
let appId = String(req.params.appIdOrName ?? '');
|
|
if (!appId) throw new HttpError(400, 'appId parameter is required');
|
|
|
|
// If not a UUID-shaped app UID or the global sentinel, look up by name.
|
|
// Which apps an actor may read is MeteringService's call, not this route's.
|
|
if (!appId.startsWith('app-') && appId !== GLOBAL_APP_KEY) {
|
|
const app = await stores.app.getByName(appId);
|
|
if (!app) throw new HttpError(404, 'App not found');
|
|
appId = app.uid;
|
|
}
|
|
|
|
const appUsage =
|
|
await services.metering.getActorCurrentMonthAppUsageDetails(
|
|
actor,
|
|
appId,
|
|
);
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
if (!multiplier) {
|
|
res.json(appUsage);
|
|
return;
|
|
}
|
|
res.json({
|
|
...scaleUsageByType(
|
|
appUsage as unknown as Record<string, unknown>,
|
|
multiplier,
|
|
),
|
|
unit: 'credits',
|
|
});
|
|
};
|
|
|
|
export const handleMeteringGlobalUsage = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const globalUsage = await services.metering.getGlobalUsage();
|
|
res.json(globalUsage);
|
|
};
|
|
|
|
// First hit walks the registries; subsequent hits serve the in-memory cache.
|
|
export const handleMeteringAllCosts = async (
|
|
_req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
if (!cachedAllCosts) {
|
|
const raw = collectAllCosts();
|
|
const multiplier = creditMultiplierFrom(extension.config);
|
|
// Price the catalogue in the same unit the usage endpoints report:
|
|
// per-unit credits when a multiplier is configured, raw otherwise.
|
|
cachedAllCosts = multiplier
|
|
? raw.map(({ ucentsPerUnit, costs_currency: _cc, ...rest }) =>
|
|
typeof ucentsPerUnit === 'number'
|
|
? {
|
|
...rest,
|
|
creditsPerUnit: toCredits(
|
|
ucentsPerUnit,
|
|
multiplier,
|
|
),
|
|
}
|
|
: rest,
|
|
)
|
|
: raw;
|
|
}
|
|
res.json({ costs: cachedAllCosts });
|
|
};
|
|
|
|
/** Dashboard reads over the per-actor KV aggregates. */
|
|
const USAGE_READ_LIMIT = {
|
|
scope: 'metering-usage',
|
|
limit: 120,
|
|
window: 60_000,
|
|
key: 'user' as const,
|
|
};
|
|
|
|
extension.get(
|
|
'/metering/usage',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringUsage,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/usage/:appIdOrName',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringUsageForApp,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/globalUsage',
|
|
{
|
|
subdomain: 'api',
|
|
adminOnly: true,
|
|
// Sums across every shard of the global aggregate. Admin-gated, so
|
|
// this is loop protection — but one accidental poll is an
|
|
// expensive minute.
|
|
rateLimit: {
|
|
scope: 'metering-global-usage',
|
|
limit: 10,
|
|
window: 60_000,
|
|
key: 'user',
|
|
},
|
|
},
|
|
handleMeteringGlobalUsage,
|
|
);
|
|
|
|
extension.get(
|
|
'/metering/allCosts',
|
|
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
|
handleMeteringAllCosts,
|
|
);
|