Files
puter/src/backend/services/user/UserAccountService.ts
T
Daniel Salazar f850155411
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
KV share handles: hardening, higher limits, batch share, uninstall data deletion, account-deletion cleanup (#4344)
* fix(kv): sign share handle ids and drop retired handles from every node's cache

New handle ids carry a short HMAC (kvh-<uuid>.<mac>); an id whose mac
does not verify is refused before any lookup. Unsigned ids keep working
but no longer fall back to the primary on a replica miss.

Retiring or changing a handle now broadcasts outer.pubsub.kv.share.forget
so every node drops its cached row. Event subscribes and delivery
rechecks refuse a handle whose owner is suspended, as the driver does.

* fix(kv): an app its user removed stops reaching shares

An app acting for a user exercises a share grant only while the user
still has the app authorized: at least one user-to-app permission row,
which every app token path leaves and revoke-user-app '*' removes. The
share itself stays; authorizing the app again restores access. The
answer rides the permission cache, keyed by the app-under-user
generation the revoke bumps, so live subscriptions go quiet too.

* feat(kv): list keys through a watch-level share handle

list with as: 'keys' through a handle needs only watch, which already
learns every key written in the region. Values and entries still need
read.

* feat(kv): filter share listings by app and by state

GET /kv/shares and GET /kv/shares/with-me take an optional appUid: an
app uid, or null for the app-less store. An account session may name
any app; an app is refused any namespace but its own. GET /kv/shares
also takes state=live|revoked|all, all by default.

* feat(puter.js): appUid and state options on the KV share listings

listShared() and listSharedByMe() pass appUid (null for the app-less
store), and listSharedByMe() passes state. Docs cover the new options
and that a watch share can list keys without their values.

* feat(kv): indexes and store counts for share-handle quotas and retention

Adds covering indexes for the owner's live count, the owner-holder pair,
app-filtered listings and retired rows, plus store methods to count a
pair's live handles and delete rows retired before a cutoff. The live
handle cache holds up to 50,000 entries; the miss cache stays at 10,000.

* feat(kv): higher share-handle caps, a per-holder cap, and sharing with several recipients

Live share handles per account go to 25,000 paid / 5,000 free, and per
app to 10,000 / 2,000. One owner may hold out 256 live handles to one
holder on every plan, which bounds the rows a revoke or unshare walks.
Re-sharing an existing region takes no slot and is never refused.

POST /kv/shares takes recipients (1 to 50) as an alternative to
recipient: one region, level and label for all. Caps and a new
per-recipient grant budget (300/min, 2,000/hour) are checked for the
whole batch up front; failures about one recipient are reported on its
item. The single-recipient request and response are unchanged and run
through the same code path.

* feat(kv): delete share handles 30 days after they stop

A daily job deletes retired share-handle rows older than 30 days, in
batches of 500. One node runs it per day; it stops on shutdown.

* feat(puter.js): share a KV prefix with several users in one call

puter.kv.share() takes an array of recipients, positionally or as
recipients in the options form, and resolves to one item per recipient:
the share, or an error with its message and code. The single-recipient
forms are unchanged.

* feat(kv): delete an app's data on uninstall, and close account-deletion gaps

Uninstall:
- POST /auth/revoke-user-app takes an optional `deleteData` boolean, only
  with `permission: '*'` (400 otherwise). AuthService.uninstallApp revokes
  every grant, deletes the app's worker sessions, then awaits
  `app.uninstalled` { userId, userUuid, appUid, deleteData }.
- With deleteData, KvShareService retires the handles the user shares out
  of that app's namespace (as the platform, in batches) and then empties
  the namespace in the background, 5,000 items per step, stopping on
  shutdown. Handles shared with the user are not touched.
- SystemKVStore.purgeNamespace(namespace, { maxItems }) deletes up to
  maxItems items of one `v1:<uuid>:<appUid>` namespace, reading keys only,
  with no mutation events or usage, and reports `done` once it is empty.
  It refuses the system namespace and anything else. flush shares its
  page-delete path.
- puter.perms.revokeApp(appUid, '*', { deleteData: true }); the desktop's
  uninstall dialog has an unchecked "Also delete this app's data" option.

Account deletion:
- EventsService settles each of a deleted account's apps on `user.delete`
  (they cascade without `app.changed`) and drops the owner's subscription
  keys.
- The flat permission entries the account held are purged in pages.

Suspension:
- `user.suspended` / `user.unsuspended` events; the share-handle store
  drops cached rows owned by that user here and on other nodes.

* fix(permission): await a peer's flat-permission deletes so emitAndWait sees them applied

* fix(kv): strongly consistent purge pages, and an uninstall purge stops once the app is authorized again

A stale empty page could end a purge as done while items remained. The
background purge after an uninstall now checks before each step that the
user hasn't authorized the app again, so data written after that is kept.

* fix(kv): flush keeps eventually consistent page reads; only a purge reads strongly consistent

* fix(user): emit user.suspended and user.unsuspended when the user store changes an account's suspension
2026-10-26 09:43:55 -07:00

317 lines
12 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 type { FlatPermRef } from '../../stores/permission/PermissionStore.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { PuterService } from '../types.js';
/**
* Home directory plus the seven folders `generateDefaultFsentries` creates. An
* account with no more than these has never had a file put in it.
*/
const PROVISIONED_FSENTRY_COUNT = 8;
/** Apps read per page when listing the ones an account owns. */
const OWNED_APPS_PAGE = 500;
/**
* Account-lifecycle operations that more than one caller needs: deleting an
* account and everything hanging off it, and measuring whether an account has
* ever actually been used.
*
* Most `user_id` foreign keys cascade, but only inside the database: S3
* objects, sites, flat permission entries and caches outlive a bare row delete.
* Audit and analytics tables are `ON DELETE SET NULL`, so their history stays.
* Go through `cascadeDelete`.
*/
export class UserAccountService extends PuterService {
/**
* Delete a user and the state that belongs to them: their files (S3 objects
* included), their sites, their sessions, and the row itself.
*
* Irreversible. Filesystem teardown failures are logged and stepped over —
* an orphaned fsentry is a smaller problem than an account that half
* survives its own deletion.
*/
async cascadeDelete(userId: number): Promise<void> {
// Capture the identifiers downstream teardown needs before the row is
// gone — the marketplace extension cancels the user's Stripe
// subscriptions off `user.delete`, keyed by uuid / customer id.
let userUuid: string | undefined;
let stripeCustomerId: string | null = null;
// Cache keys the row answers under, captured while it still exists.
let cachedIdentity: UserRow | null = null;
try {
// `SELECT *`: a schema without `stripe_customer_id` still yields the rest.
const rows = (await this.clients.db.pread(
'SELECT * FROM `user` WHERE `id` = ?',
[userId],
)) as Array<Record<string, unknown>>;
userUuid = rows[0]?.uuid as string | undefined;
stripeCustomerId =
(rows[0]?.stripe_customer_id as string | null) ?? null;
cachedIdentity = (rows[0] as UserRow | undefined) ?? null;
} catch (e) {
console.warn('[cascade-delete-user] identifier lookup failed:', e);
}
// Same reason, one table over: the membership row cascades on delete.
const seat =
(await this.services.team?.captureSeatForBilling(userId)) ?? null;
// The SQL rows cascade with the user; the flat entries are keyed on the
// holder, so without this the access outlives the issuer.
let issuedRefs: FlatPermRef[] = [];
try {
issuedRefs =
await this.stores.permission.listUserUserPermRefsIssuedBy(
userId,
);
} catch (e) {
console.warn(
'[cascade-delete-user] issued-grant lookup failed:',
e,
);
}
try {
await this.services.fs.removeAllForUser(userId);
} catch (e) {
// Proceed with user-row delete anyway — orphaned fsentries are
// better than a resurrected account.
console.warn('[cascade-delete-user] fs cleanup failed:', e);
}
try {
await this.#deleteSubdomains(userId);
} catch (e) {
console.warn('[cascade-delete-user] subdomain cleanup failed:', e);
}
// The user's apps cascade away with the row, with no `app.changed`, so
// `user.delete` names them for what was tied to them.
let appUids: string[] = [];
try {
appUids = await this.#ownedAppUids(userId);
} catch (e) {
console.warn('[cascade-delete-user] owned-app lookup failed:', e);
}
// Record them first so a hosted origin doesn't hand their uids to the
// subdomain's next owner.
await this.stores.app.recordDeletedForOwner(userId);
// Other users' sites and workers tied to those apps cascade too.
const appSites = await this.stores.subdomain.listAppCascade({
ownerUserId: userId,
});
// Sessions FK is SET NULL, so delete explicitly to avoid dangling rows.
await this.clients.db.write(
'DELETE FROM `sessions` WHERE `user_id` = ?',
[userId],
);
await this.clients.db.write('DELETE FROM `user` WHERE `id` = ?', [
userId,
]);
if (cachedIdentity) await this.stores.user.markDeleted(cachedIdentity);
else await this.stores.user.invalidateById(userId);
await this.stores.subdomain.forgetCascadeDeleted(appSites);
if (issuedRefs.length > 0) {
try {
await this.stores.permission.delFlatUserPerms(issuedRefs);
} catch (e) {
console.warn(
'[cascade-delete-user] issued-grant flat purge failed:',
e,
);
}
}
// Grants the account held live in the flat view under its id, which
// nothing else removes.
try {
await this.stores.permission.purgeFlatUserPermsHeldBy(userId);
} catch (e) {
console.warn(
'[cascade-delete-user] held-grant flat purge failed:',
e,
);
}
// Fire-and-forget: let listeners purge external state tied to the
// account (Stripe subscriptions are cancelled immediately, without
// proration). Emitted after the row delete — listeners key off the
// payload, not the DB row.
try {
this.clients.event?.emit(
'user.delete',
{
user_id: userId,
user_uuid: userUuid,
stripe_customer_id: stripeCustomerId,
appUids,
},
{},
);
} catch {
// ignore — event emission shouldn't block deletion
}
// Tells prod to stop charging the owner for this seat.
this.services.team?.emitSeatDeleted(seat);
// The membership row cascaded away, so anything keyed on it is stale.
if (seat) {
try {
await this.services.team?.forgetSeat(seat);
} catch (e) {
console.warn(
'[cascade-delete-user] seat cache cleanup failed:',
e,
);
}
}
}
/** The uids of every app this user owns, a page at a time. */
async #ownedAppUids(userId: number): Promise<string[]> {
const uids: string[] = [];
let afterId: number | undefined;
for (;;) {
const apps = (await this.stores.app.list({
ownerUserId: userId,
afterId,
limit: OWNED_APPS_PAGE,
})) as Array<{ id: number; uid: string }>;
uids.push(...apps.map((app) => app.uid));
if (apps.length < OWNED_APPS_PAGE) return uids;
afterId = apps[apps.length - 1]!.id;
}
}
/**
* Delete the user's sites one by one, emitting `subdomain.delete` for each
* as the driver does. The FK cascade would drop the rows silently, leaving
* cached lookups and deployed scripts behind.
*/
async #deleteSubdomains(userId: number): Promise<void> {
let afterId: number | undefined;
for (;;) {
const rows = (await this.stores.subdomain.listByUserId(userId, {
afterId,
})) as Array<{ id: number; uuid: string; subdomain: string }>;
if (rows.length === 0) return;
for (const row of rows) {
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
userId,
});
try {
this.clients.event.emit(
'subdomain.delete',
{ subdomain: row.subdomain, uid: String(row.uuid) },
{},
);
} catch {
// Non-critical.
}
}
afterId = rows[rows.length - 1]!.id;
}
}
/**
* Evidence that an account has been used for something — the signals that
* separate "a row a race created and nobody ever touched" from "somebody's
* account".
*
* Deliberately generous about what counts. A false "in use" costs a row
* that sticks around; a false "unused" destroys somebody's files.
*
* `fsentryCount` is compared against the folders provisioned at signup, so
* an account whose Desktop is still empty reads as untouched.
*/
async getUsageSignals(userId: number): Promise<{
userId: number;
signals: string[];
inUse: boolean;
lastActivityTs: string | null;
}> {
// Each of these is a capped count, not a real one: the subquery stops at
// `cap` rows, so a user with a million fsentries costs the same as one
// with nine. We only ever compare against a small threshold.
const cappedCount = async (
table: string,
column: string,
cap: number,
): Promise<number> => {
const sql =
`SELECT COUNT(*) AS n FROM ` +
`(SELECT 1 FROM \`${table}\` WHERE \`${column}\` = ? LIMIT ${cap}) t`;
try {
const rows = (await this.clients.db.read(sql, [
userId,
])) as Array<Record<string, unknown>>;
return Number(rows[0]?.n ?? 0);
} catch (e) {
// A signal we cannot read is not a signal that is absent —
// report it as present so the caller errs toward keeping.
console.warn('[user-usage] signal query failed:', sql, e);
return cap;
}
};
// Through the store rather than a hand-written column list: several of
// the columns read below (`stripe_customer_id`, `card_fingerprint`) are
// prod-only additions that a self-hosted schema may not carry, and
// naming them in SQL turns their absence into a thrown query instead of
// an absent signal.
const [
row,
sessionCount,
appCount,
subdomainCount,
oidcCount,
fsentryCount,
] = await Promise.all([
this.stores.user.getById(userId, { force: true }),
cappedCount('sessions', 'user_id', 1),
cappedCount('apps', 'owner_user_id', 1),
cappedCount('subdomains', 'user_id', 1),
cappedCount('user_oidc_providers', 'user_id', 1),
cappedCount('fsentries', 'user_id', PROVISIONED_FSENTRY_COUNT + 1),
]);
const signals: string[] = [];
if (sessionCount > 0) signals.push('sessions');
if (appCount > 0) signals.push('apps');
if (subdomainCount > 0) signals.push('subdomains');
if (oidcCount > 0) signals.push('oidc-link');
if (fsentryCount > PROVISIONED_FSENTRY_COUNT) signals.push('files');
if (row?.stripe_customer_id) signals.push('stripe-customer');
if (row?.card_fingerprint) signals.push('card-verified');
if (row?.phone) signals.push('phone-verified');
if (row?.last_activity_ts) signals.push('activity');
return {
userId,
signals,
inUse: signals.length > 0,
lastActivityTs: (row?.last_activity_ts as string | null) ?? null,
};
}
}