Files
puter/src/backend/core/http/HttpError.ts
T
Juan Castro da2b42d7fe feat: add TeamService workspace lifecycle and disable/re-enable
Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master
account, and the whole of offboarding.

`createWorkspace` admits the creator with org_owned = 0, which is what makes
the master pay for itself and stay an invalid target of every member route.
`checkOwnerInvariant` asserts the rule no dialect can express -- the owner is
a member with org_owned = 0 and the only such member -- and a test breaks it
deliberately, since the schema cannot refuse a second one.

Three authority checks: 404 to a stranger so the endpoint is not an existence
oracle, 403 to a member who is not the master, and the master refused as a
target of member routes.

Handle problems surface as 400 (unusable) or 409 (taken), including the
unique-index race. `TeamStore` throws a bare Error, which the server would turn
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable writes `user.suspended` as well as suspended_at and suspended_reason.
PUT-1707 named only the latter two, but those are siblings added by 0061 and
0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither.
Setting only the timestamp and reason would have recorded a disable that never
took effect, and disable is the whole of offboarding here.

Sessions are dropped through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key with its double-delete
pattern; without that a disabled member keeps authenticating from cache for the
session TTL, which is exactly the "next request, not after a cache TTL"
property disable is supposed to have. Revoking also preserves last_ip and
last_user_agent, which the member-facing audit view reads.

Files are untouched and re-enable restores the account.

Adds team_not_found, not_the_master_account and not_an_org_account to the
HttpError legacy codes, which the controller also needs.

Billing events, invalidateActorSubscription, audit rows and the GUI push are
deliberately not here -- they belong to phase 3 and PUT-1708.
2026-09-03 15:57:23 -04:00

170 lines
5.5 KiB
TypeScript

// NOT ALL OF THEM, ADD OLD ONES AS NEEDED, IF NEEDED. DO NOT ADD NEW ONES THOUGH.
export type LegacyErrorCodes =
| 'payment_account_not_set_up'
| 'unknown_error'
| 'disallowed_value'
| 'invalid_token'
| 'item_with_same_name_exists'
| 'cannot_move_directory_into_itself'
| 'cannot_copy_directory_into_itself'
| 'directory_depth_limit_exceeded'
| 'cannot_move_to_root'
| 'cannot_copy_to_root'
| 'cannot_write_to_root'
| 'cannot_overwrite_a_directory'
| 'cannot_read_a_directory'
| 'source_and_dest_are_the_same'
| 'dest_is_not_a_directory'
| 'dest_does_not_exist'
| 'source_does_not_exist'
| 'subject_does_not_exist'
| 'shortcut_target_not_found'
| 'shortcut_target_is_a_directory'
| 'shortcut_target_is_a_file'
| 'forbidden'
| 'storage_limit_reached'
| 'internal_error'
| 'response_timeout'
| 'app_name_already_in_use'
| 'app_index_url_already_in_use'
| 'subdomain_limit_reached'
| 'subdomain_reserved'
| 'subdomain_not_owned'
| 'email_already_in_use'
| 'email_not_allowed'
| 'username_already_in_use'
| 'too_many_username_changes'
| 'token_invalid'
| 'insufficient_funds'
| 'token_missing'
| 'token_auth_failed'
| 'token_expired'
| 'permission_denied'
| 'account_suspended'
| 'bad_request'
| 'not_found'
| 'conflict'
| 'unauthorized'
| 'too_many_requests'
| 'oidc_revalidation_required'
| 'user_tokens_only'
| 'session_required'
| 'temporary_accounts_not_allowed'
| 'password_required'
| 'password_mismatch'
| 'field_not_allowed_for_create'
| 'account_is_not_verified'
| 'app_or_api_token_required'
| 'team_not_found'
| 'not_the_workspace_owner'
| 'not_an_org_account';
/**
* 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/](https://www.gnu.org/licenses/).
*/
/** Options accepted by `HttpError`. All optional. */
export interface HttpErrorOptions {
/** Underlying error. Set as the standard `Error.cause`. */
cause?: unknown;
/**
* Stable wire-format error code that legacy clients key on (e.g.
* `item_with_same_name_exists`, `forbidden`, `subject_does_not_exist`).
* Serialized as `code` in the response body for back-compat.
*/
legacyCode?: LegacyErrorCodes | (string & {});
/**
* Modern, structured error code. If both `legacyCode` and `code` are set,
* the legacy one takes the `code` slot in the response body and `code` is
* emitted as `errorCode`, so clients keying on either field find what they
* expect.
*/
code?: string;
/** Additional fields merged into the response body. */
fields?: Record<string, unknown>;
/**
* Skip the terminal alarm gate for this error. Set it only where the call
* site already knows the failure is expected and arrives in proportion to
* traffic — an upstream rate limit on a zero-cost model, say. Never
* serialized to the client.
*/
noAlarm?: boolean;
}
/**
* The single error type controllers and services throw to surface an HTTP
* failure. The terminal `errorHandler` middleware catches it, serializes a JSON
* body, and sets the response status.
*
* Usage:
*
* ```ts
* throw new HttpError(404, 'Item not found');
* throw new HttpError(409, 'Cannot overwrite directory', {
* legacyCode: 'is_directory',
* });
* throw new HttpError(403, 'Forbidden', {
* legacyCode: 'forbidden',
* fields: { target },
* });
* ```
*
* Express 5 forwards thrown errors (sync and async) to error-handling
* middleware automatically — no `next(err)` ceremony required.
*/
export class HttpError extends Error {
readonly statusCode: number;
readonly legacyCode?: LegacyErrorCodes | (string & {});
readonly code?: string;
readonly fields?: Record<string, unknown>;
readonly noAlarm?: boolean;
constructor(
statusCode: number,
message: string,
options: HttpErrorOptions = {},
) {
super(
message,
options.cause !== undefined ? { cause: options.cause } : undefined,
);
this.name = 'HttpError';
this.statusCode = statusCode;
this.legacyCode = options.legacyCode;
this.code = options.code;
this.fields = options.fields;
this.noAlarm = options.noAlarm;
}
}
/**
* Type guard that survives module-graph duplication (defensive — cross-realm
* `instanceof` can be unreliable in test setups). Pure runtime convenience;
* normal callers can use `instanceof HttpError`.
*/
export const isHttpError = (e: unknown): e is HttpError => {
if (e instanceof HttpError) return true;
return Boolean(
e &&
typeof e === 'object' &&
(e as { name?: unknown }).name === 'HttpError' &&
typeof (e as { statusCode?: unknown }).statusCode === 'number',
);
};