mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734715f461 | ||
|
|
b290c1d922 | ||
|
|
dd068e31d0 | ||
|
|
ea63c3093e | ||
|
|
e8236a100c | ||
|
|
20b3b88e39 | ||
|
|
90cffbfcde | ||
|
|
25acf14c8b | ||
|
|
cbae39a08f | ||
|
|
be781c5f1b | ||
|
|
4cb0730875 | ||
|
|
00b2fcdea3 | ||
|
|
f70347b579 | ||
|
|
3d9691b11a | ||
|
|
c2fe813929 | ||
|
|
ef7fc05dcc | ||
|
|
1c2d33f149 | ||
|
|
b1fdc8d665 | ||
|
|
1ecc23cf83 | ||
|
|
7230580b18 | ||
|
|
1460751b41 | ||
|
|
4f96d371f4 | ||
|
|
c5cf1dc390 | ||
|
|
503a450b40 | ||
|
|
f4dfbd735b | ||
|
|
81402918d2 | ||
|
|
32c950de5b | ||
|
|
792361507e | ||
|
|
4f267fb83e | ||
|
|
afad3e37bf | ||
|
|
a6b2161b2c | ||
|
|
9e864e9574 | ||
|
|
79d4201f12 | ||
|
|
0c2d7dfa34 | ||
|
|
2a6d2fea32 | ||
|
|
1dec5e90c4 | ||
|
|
566298d89c | ||
|
|
6bb5eacf61 | ||
|
|
390a83c860 | ||
|
|
8f23bf7b75 | ||
|
|
aa85dddd38 | ||
|
|
8284f35b93 | ||
|
|
ccec3ce39b | ||
|
|
60686643b3 | ||
|
|
61bfcbdc83 | ||
|
|
c8985b621d | ||
|
|
42299a64ed | ||
|
|
344db5cb0b | ||
|
|
66824f607d | ||
|
|
66dedf3082 | ||
|
|
95da802b13 | ||
|
|
0491d5fea7 | ||
|
|
d202be10a9 | ||
|
|
2c17476c07 | ||
|
|
06d94534e8 | ||
|
|
aea828ff12 | ||
|
|
c24f0fe08b | ||
|
|
085ac116c7 | ||
|
|
5538a5d24b | ||
|
|
14c14fb5c5 | ||
|
|
41fc8d890b | ||
|
|
45bdc46df0 | ||
|
|
82ca390bf4 | ||
|
|
edfd67a83c | ||
|
|
ff16ec8900 | ||
|
|
c7edfc0c74 | ||
|
|
09f30d693c |
No files matched your search
@@ -13,6 +13,9 @@ on:
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
# The type-check job below reads these.
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.build.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
permissions:
|
||||
@@ -20,6 +23,35 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# `tsconfig.build.json` builds with `noCheck: true`, so nothing else in CI
|
||||
# runs the type checker and a missing export compiles to `undefined`,
|
||||
# surfacing only when the call is finally reached at runtime. This diffs
|
||||
# against tools/typecheck-baseline.json and fails only on *new* errors.
|
||||
#
|
||||
# The consuming repo runs an equivalent gate, but only once someone bumps the
|
||||
# submodule pointer — too late to keep the error off this repo's default
|
||||
# branch. Hence a gate here, on this repo's own pull requests.
|
||||
#
|
||||
# Its own job rather than a step in `test`: that one runs a base/PR matrix for
|
||||
# coverage comparison, and the check only needs the PR ref, once.
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check (new errors only)
|
||||
run: npm run typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
@@ -23,6 +23,33 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# The JSDoc in src/puter-js/src is the source of truth for the SDK's public
|
||||
# types. The declarations shipped to npm are generated from it at build time
|
||||
# and never committed, so what needs guarding is the JSDoc itself: this
|
||||
# generates the declarations and type-checks the published surface without
|
||||
# skipLibCheck, which is how broken re-exports used to go unnoticed.
|
||||
#
|
||||
# Its own job rather than a step in `test`: that one builds bundles and
|
||||
# installs a browser, and this needs neither.
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check the puter.js JSDoc produces declarations that type-check
|
||||
run: npm run check:puterjs:types
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -98,24 +98,23 @@ Follow the same layered structure inside an extension — unless it only needs a
|
||||
|
||||
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
|
||||
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `KV.js`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), hand-maintained type declarations in [src/puter-js/types/](src/puter-js/types/), API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `kv/`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), generated (gitignored) type declarations in `src/puter-js/types/`, API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
|
||||
|
||||
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. Reference the hand-maintained declarations in [src/puter-js/types/](src/puter-js/types/) with `import(...)` types rather than re-declaring shapes:
|
||||
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. **The JSDoc is the source of truth for the SDK's types.** `src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it so TypeScript consumers get declarations, and git ignores it — it is never committed and never edited by hand. `npm run check:puterjs:types` generates it in CI and type-checks the result without `skipLibCheck`. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts), which decides what is public and re-exports the generated names.
|
||||
|
||||
Declare a shape where it belongs, and reference it with an `import(...)` type from elsewhere. A shape more than one file needs lives in the module's `types.js`; a shape shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); a shape with one consumer can stay next to it:
|
||||
|
||||
```js
|
||||
/** @typedef {import('../../types/modules/ai').ChatOptions} ChatOptions */
|
||||
|
||||
/** @type {ChatOptions} */
|
||||
const options = { model: 'gpt-5-nano' };
|
||||
/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */
|
||||
```
|
||||
|
||||
When a shape only exists locally, declare it as an inline object literal — not the `@typedef {Object}` + `@property` list form — and use `unknown` over `*`:
|
||||
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string, value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value, matching the `.d.ts` declarations exactly. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean.
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
|
||||
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
|
||||
|
||||
@@ -133,7 +132,7 @@ Every SDK change carries all five of the following — a puter.js PR missing one
|
||||
1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
|
||||
2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
|
||||
3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
|
||||
4. **Types.** Update [src/puter-js/types/modules/](src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts` / `types/puter.d.ts`. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
|
||||
4. **Types.** Type the change in JSDoc on the implementation, then run `npm run check:puterjs:types` to confirm it still produces declarations that type-check. Name any new type in [src/puter-js/index.d.ts](src/puter-js/index.d.ts) if consumers should be able to import it. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
|
||||
5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
|
||||
|
||||
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
|
||||
|
||||
+20
-2
@@ -437,6 +437,24 @@
|
||||
],
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
// When true, all metering checks pass — no per-actor limits enforced.
|
||||
"unlimitedMetering": false
|
||||
// When true, every account resolves to an unlimited policy: usage is still
|
||||
// recorded, but nothing is ever refused for lack of budget. This is the
|
||||
// setting for a deployment with no way to buy more — without it, accounts
|
||||
// are held to the free monthly allowance and start getting 402s from the AI
|
||||
// surfaces, file transfers and KV once they pass it.
|
||||
"unlimitedMetering": false,
|
||||
|
||||
// Whether an account that has spent its whole allowance is refused the
|
||||
// operations that spend it — file transfers, KV calls. Recording is
|
||||
// unaffected either way. `workers` extends the same refusal to
|
||||
// worker-driven calls, which are exempt by default because a deployed
|
||||
// worker has nowhere to surface a payment prompt.
|
||||
// "meteringEnforcement": { "enabled": true, "workers": false },
|
||||
|
||||
// Fleet-wide spend rate, in micro-cents per minute, past which metering
|
||||
// raises the `metering:excessiveGlobalUsageRate` alarm. Omit it (the
|
||||
// default) to leave the check off: the only useful value is a multiple of
|
||||
// what this deployment's normal traffic costs, so it has to be measured
|
||||
// rather than guessed, and a stale number here alarms on healthy growth.
|
||||
// "maxGlobalUsagePerMinute": 200000000
|
||||
}
|
||||
+11
-5
@@ -6,9 +6,9 @@ transports by **severity**.
|
||||
|
||||
```ts
|
||||
this.clients.alarm.create(
|
||||
`driver_rate_limit_hit:${iface}:${method}`, // de-dupe key
|
||||
`Driver rate limit hit on ${iface}:${method}`, // what a human reads
|
||||
{ iface, method, userUuid }, // context fields
|
||||
`metering_write_failed:${userUuid}`, // de-dupe key
|
||||
'Metering write failed', // what a human reads
|
||||
{ userUuid, appId, error }, // context fields
|
||||
'info', // severity
|
||||
);
|
||||
```
|
||||
@@ -37,8 +37,14 @@ pass one explicitly unless you really mean "page someone".
|
||||
|
||||
- Did the server fail to do its job in a way nobody expected? → `critical`
|
||||
- Is a background job, rate, or dependency degraded? → `warning`
|
||||
- Is this a user doing something notable (hitting a limit, tripping an abuse
|
||||
heuristic, overspending)? → `info`
|
||||
- Is this a user doing something notable (tripping an abuse heuristic,
|
||||
overspending)? → `info`
|
||||
- Did one of *our own* limits reject a caller — a rate limit, a concurrency
|
||||
cap, a quota? → don't alarm at all. The limit doing its job is not an
|
||||
event; the 429 is the whole signal, and alarming on it only produces noise
|
||||
proportional to traffic. An *upstream provider* rate-limiting us is the
|
||||
opposite case and still alarms (`upstream_rate_limited`, `info`) — that one
|
||||
is not something we chose.
|
||||
|
||||
An extension whose signals are all one tier can default its own local
|
||||
`raiseAlarm` helper to that tier instead of repeating it at every call site —
|
||||
|
||||
@@ -54,6 +54,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
@@ -62,7 +63,10 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
|
||||
### 4. Types
|
||||
|
||||
- Add or extend the declaration in [src/puter-js/types/modules/](../src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts`. Declarations must match the runtime exactly — optionality, defaults, and return types included.
|
||||
- Type the method where you wrote it, in JSDoc: `@param`/`@returns` on the implementation, one `@overload` block per accepted call form, and `@typedef {Object}` + `@property` for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand.
|
||||
- Put a shape more than one file needs in the module's `types.js` (e.g. [src/puter-js/src/modules/kv/types.js](../src/puter-js/src/modules/kv/types.js)); anything shared across modules goes in [src/puter-js/src/lib/types.js](../src/puter-js/src/lib/types.js). A shape with one consumer can stay next to it.
|
||||
- Run `npm run check:puterjs:types` — it generates the declarations and type-checks the published surface without `skipLibCheck`. **Never edit anything under `src/puter-js/types/`**: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it.
|
||||
- Name the new type in [src/puter-js/index.d.ts](../src/puter-js/index.d.ts) if consumers should be able to import it. That file is the one hand-written declaration in the package: it decides what is public and re-exports nothing else.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
|
||||
@@ -370,6 +370,37 @@ Default is 100 MB per user.
|
||||
|
||||
Set `is_storage_limited: false` for unlimited (bounded by host disk).
|
||||
|
||||
### Usage metering and budgets
|
||||
|
||||
Puter meters what an account costs to serve — bytes sent back, object-store
|
||||
requests, KV capacity, AI tokens — against a monthly budget, and refuses the
|
||||
operations that spend it once that budget is gone. The refusal is a `402` with
|
||||
code `insufficient_funds`; reads that only describe things, and every kind of
|
||||
deletion, stay available so an account can always see what it has and clear it.
|
||||
|
||||
On a self-hosted install this is almost certainly not what you want. There is
|
||||
nowhere to buy more, so accounts are held to the free monthly allowance
|
||||
(US$0.25 of measured cost — roughly 2 GiB of downloads) and start being refused
|
||||
after that. Turn it off:
|
||||
|
||||
```json
|
||||
"unlimitedMetering": true
|
||||
```
|
||||
|
||||
Every account then resolves to an unlimited policy. Usage is still recorded, so
|
||||
the dashboard still shows what is being consumed; nothing is ever refused for
|
||||
lack of budget.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
"meteringEnforcement": { "enabled": false }
|
||||
```
|
||||
|
||||
Calls driven by a deployed worker are exempt from enforcement by default,
|
||||
because a worker has no prompt to show and nobody watching it fail. Set
|
||||
`"meteringEnforcement": { "workers": true }` to include them.
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
@@ -2,6 +2,14 @@ import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
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
|
||||
@@ -50,17 +58,42 @@ const parseIntParam = (
|
||||
* 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 +
|
||||
* `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.
|
||||
* 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,
|
||||
|
||||
@@ -81,6 +81,16 @@ export const handleInstalledApps = async (
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
scope: 'installed-apps',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
handleInstalledApps,
|
||||
);
|
||||
+29
-5
@@ -3,6 +3,7 @@ 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 type { Request, Response } from 'express';
|
||||
@@ -18,7 +19,7 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
const collect = (
|
||||
source: Record<string, unknown>,
|
||||
kind: 'driver' | 'controller',
|
||||
kind: 'driver' | 'controller' | 'service',
|
||||
) => {
|
||||
for (const [name, instance] of Object.entries(source)) {
|
||||
const fn = (
|
||||
@@ -43,6 +44,9 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -110,26 +114,46 @@ export const handleMeteringAllCosts = async (
|
||||
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 },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringUsage,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/usage/:appIdOrName',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringUsageForApp,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/globalUsage',
|
||||
{ subdomain: 'api', adminOnly: true },
|
||||
{
|
||||
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 },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringAllCosts,
|
||||
);
|
||||
@@ -109,6 +109,9 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// Account creation time, in unix seconds.
|
||||
expect(typeof body.created_ts).toBe('number');
|
||||
expect(body.created_ts).toBeGreaterThan(0);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
@@ -132,7 +135,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips desktop_bg_* and human_readable_age fields for app actors', async () => {
|
||||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -154,6 +157,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Account age, in either form, is not exposed to apps.
|
||||
expect(body.created_ts).toBeUndefined();
|
||||
// Directories are user-only.
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
+33
-11
@@ -14,6 +14,15 @@ const timeago = (() => {
|
||||
return new TimeAgo('en-US');
|
||||
})();
|
||||
|
||||
// User timestamps come off the DB as SQL datetime strings; the wire format
|
||||
// for all of them is unix seconds. Unparseable values are dropped rather
|
||||
// than sent as NaN.
|
||||
const toUnixSeconds = (value: unknown): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const ms = new Date(value as string | number | Date).getTime();
|
||||
return Number.isNaN(ms) ? undefined : Math.round(ms / 1000);
|
||||
};
|
||||
|
||||
// Allowlist of `config.feature_flags` keys safe to surface via /whoami.
|
||||
// Anything not listed here stays server-side, so internal flags
|
||||
// (payment_bypass, staff_only_*, etc.) cannot leak by accident. Add a
|
||||
@@ -98,6 +107,7 @@ export const handleWhoami = async (
|
||||
: undefined,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
created_ts: toUnixSeconds(user.timestamp),
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
@@ -141,14 +151,9 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
// Last activity
|
||||
if (user.last_activity_ts) {
|
||||
try {
|
||||
details.last_activity_ts = Math.round(
|
||||
new Date(user.last_activity_ts as string).getTime() / 1000,
|
||||
);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
details.last_activity_ts = lastActivityTs;
|
||||
}
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
@@ -164,6 +169,7 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
@@ -183,8 +189,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
@@ -196,6 +201,23 @@ export const handleWhoami = async (
|
||||
|
||||
extension.get(
|
||||
'/whoami',
|
||||
{ subdomain: 'api', requireAuth: true, allowUnconfirmed: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
// The GUI polls this, and each call fans out to every `whoami`
|
||||
// event listener — so it costs more than the response suggests.
|
||||
//
|
||||
// It is also the call everything else leans on to find out who it is
|
||||
// talking to, so it rides along with unrelated work rather than
|
||||
// arriving at its own pace: the ceiling has to clear whatever the
|
||||
// busiest session is doing, not what a person clicks.
|
||||
rateLimit: {
|
||||
scope: 'whoami',
|
||||
limit: 1_800,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
handleWhoami,
|
||||
);
|
||||
Generated
+4
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "puter.com",
|
||||
"version": "2.5.1",
|
||||
"version": "26.07",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "puter.com",
|
||||
"version": "2.5.1",
|
||||
"version": "26.07",
|
||||
"license": "AGPL-3.0-only",
|
||||
"workspaces": [
|
||||
"src/*",
|
||||
@@ -1304,6 +1304,7 @@
|
||||
},
|
||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"extraneous": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -20252,7 +20253,7 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.5.4",
|
||||
"version": "2.6.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
|
||||
@@ -67,6 +67,9 @@
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
"prepare": "husky",
|
||||
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
|
||||
"check:puterjs:types": "node tools/checkPuterjsTypes.mjs",
|
||||
"typecheck": "node tools/typecheck.mjs",
|
||||
"typecheck:update": "node tools/typecheck.mjs --update",
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
|
||||
@@ -371,6 +371,24 @@ describe('AlarmClient alarm registry', () => {
|
||||
expect(client.get('flap')?.occurrences).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('caps retained occurrences while still counting every repeat', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
client.create('hot', `occurrence ${i}`, { i });
|
||||
}
|
||||
|
||||
const alarm = client.get('hot')!;
|
||||
expect(alarm.count).toBe(50);
|
||||
expect(alarm.occurrences).toHaveLength(20);
|
||||
expect(alarm.timestamps).toHaveLength(20);
|
||||
// The window kept is the most recent one, not the oldest.
|
||||
expect(alarm.occurrences[19].message).toBe('occurrence 49');
|
||||
// Trimming history must not rewind what the transports are told.
|
||||
expect(seen[49]).toMatchObject({ repeatCount: 50, isRepeat: true });
|
||||
});
|
||||
|
||||
it('names anonymous handlers by their registration order', () => {
|
||||
const client = makeClient();
|
||||
client.addAlertHandler(async () => {
|
||||
|
||||
@@ -60,6 +60,15 @@ interface RegisteredHandler {
|
||||
|
||||
/** Severity used when neither the call site nor config picks one. */
|
||||
const FALLBACK_SEVERITY: PagerSeverity = 'critical';
|
||||
/**
|
||||
* How many recent occurrences an alarm keeps. An alarm is never cleared unless
|
||||
* something calls `clear`, and a hot one repeats for as long as the fault lasts
|
||||
* — so retaining every occurrence means retaining every message and field set
|
||||
* it was ever raised with, request bodies and actors included, for the life of
|
||||
* the process. The last few are what a human reads; the rest is only a count,
|
||||
* and `count` keeps that.
|
||||
*/
|
||||
const OCCURRENCE_HISTORY_LIMIT = 20;
|
||||
/** Keeps `info` alarms out of the paging system unless config says otherwise. */
|
||||
const DEFAULT_PAGERDUTY_MIN_SEVERITY: PagerSeverity = 'warning';
|
||||
/** Slack's ceiling once a pager exists: chat gets what doesn't page. */
|
||||
@@ -321,6 +330,7 @@ export class AlarmClient extends PuterClient {
|
||||
started: Date.now(),
|
||||
// `recordOccurrence` below stamps the first occurrence; seeding one
|
||||
// here too would report every alarm as one occurrence ahead.
|
||||
count: 0,
|
||||
timestamps: [],
|
||||
occurrences: [],
|
||||
};
|
||||
@@ -381,16 +391,25 @@ export class AlarmClient extends PuterClient {
|
||||
message: string,
|
||||
fields: AlarmFields,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
alarm.message = message;
|
||||
alarm.fields = { ...alarm.fields, ...fields };
|
||||
alarm.timestamps.push(Date.now());
|
||||
alarm.count++;
|
||||
if (fields.error) alarm.error = fields.error;
|
||||
|
||||
alarm.occurrences.push({
|
||||
message,
|
||||
fields,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
alarm.timestamps.push(now);
|
||||
alarm.occurrences.push({ message, fields, timestamp: now });
|
||||
|
||||
if (alarm.timestamps.length > OCCURRENCE_HISTORY_LIMIT) {
|
||||
alarm.timestamps.splice(
|
||||
0,
|
||||
alarm.timestamps.length - OCCURRENCE_HISTORY_LIMIT,
|
||||
);
|
||||
alarm.occurrences.splice(
|
||||
0,
|
||||
alarm.occurrences.length - OCCURRENCE_HISTORY_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private applyKnownErrors(alarm: Alarm): void {
|
||||
@@ -438,7 +457,7 @@ export class AlarmClient extends PuterClient {
|
||||
this.applyKnownErrors(alarm);
|
||||
|
||||
console.warn(
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.timestamps.length})`,
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.count})`,
|
||||
);
|
||||
|
||||
if (alarm.noAlert) return;
|
||||
@@ -476,7 +495,7 @@ export class AlarmClient extends PuterClient {
|
||||
alarm.severity = resolved;
|
||||
|
||||
const fieldsClean = cleanFields(alarm.fields);
|
||||
const repeatCount = alarm.timestamps.length;
|
||||
const repeatCount = alarm.count;
|
||||
|
||||
const id = alarm.id || 'something-bad';
|
||||
|
||||
|
||||
@@ -48,7 +48,17 @@ export interface Alarm extends AlarmOptions {
|
||||
fields: AlarmFields;
|
||||
error?: Error;
|
||||
started: number;
|
||||
/**
|
||||
* Every occurrence ever counted, including those aged out of the two lists
|
||||
* below.
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Timestamps of the most recent occurrences only — see
|
||||
* `OCCURRENCE_HISTORY_LIMIT`.
|
||||
*/
|
||||
timestamps: number[];
|
||||
/** The most recent occurrences only — see `OCCURRENCE_HISTORY_LIMIT`. */
|
||||
occurrences: AlarmOccurrence[];
|
||||
severity?: PagerSeverity;
|
||||
noAlert?: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
isNeverSentError,
|
||||
isRetriableError,
|
||||
isRolledBackError,
|
||||
} from './retriableErrors.js';
|
||||
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
||||
@@ -361,8 +362,14 @@ export class SQLBatcher {
|
||||
// Run one fallback item, retrying transient failures with backoff.
|
||||
// A read-only batcher may retry anything transient; a batcher that
|
||||
// carries writes only retries failures where the statement provably
|
||||
// never reached the server — a write that died mid-flight may have
|
||||
// committed, and re-running it would double-apply.
|
||||
// did not apply — either it never reached the server, or the server
|
||||
// rolled it back itself. A write that died mid-flight may have
|
||||
// committed, and re-running that one would double-apply.
|
||||
//
|
||||
// Lock contention lands in the second group and is worth retrying rather
|
||||
// than surfacing: an item is a single statement, so a deadlock victim has
|
||||
// been fully undone, and the caller sees an unhandled 500 for what the
|
||||
// database is telling us to just run again.
|
||||
async #runFallbackItem(b) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
@@ -379,8 +386,8 @@ export class SQLBatcher {
|
||||
};
|
||||
} catch (error) {
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error)
|
||||
: isNeverSentError(error);
|
||||
? isRetriableError(error) || isRolledBackError(error)
|
||||
: isNeverSentError(error) || isRolledBackError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
@@ -208,6 +208,36 @@ describe('SQLBatcher', () => {
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries a deadlocked write instead of surfacing it', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ER_LOCK_DEADLOCK');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
// The victim statement was rolled back by the server, so re-running it
|
||||
// can't double-apply — the caller should never see the deadlock.
|
||||
const result = await batcher.query('UPDATE notification SET x', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('gives up on a write that deadlocks past the retry budget', async () => {
|
||||
const conn = makeConnection(() => {
|
||||
throw makeError('ER_LOCK_DEADLOCK');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('UPDATE hot SET x', [])).rejects.toMatchObject(
|
||||
{ code: 'ER_LOCK_DEADLOCK' },
|
||||
);
|
||||
});
|
||||
|
||||
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 60;
|
||||
const CURRENT_SCHEMA_VERSION = 61;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
|
||||
@@ -94,6 +94,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 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/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065. Opt-in is the new `apps.feedback_enabled` column (developer-writable
|
||||
-- through the regular `puter.apps.update` path). Each row of `app_feedback`
|
||||
-- is one message a signed-in user submitted through the GUI feedback dialog;
|
||||
-- a copy is emailed to the app owner unless the per-app daily email cap
|
||||
-- suppressed it (`email_sent` records which). `app_uid` is denormalized
|
||||
-- alongside `app_id` so rows stay attributable after an app is deleted.
|
||||
-- `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- always runs first) and the table uses `CREATE TABLE IF NOT EXISTS`, so the
|
||||
-- directory can replay safely.
|
||||
|
||||
CALL _puter_add_col('apps', 'feedback_enabled', '`feedback_enabled` tinyint(1) DEFAULT ''0''');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`uid` CHAR(36) NOT NULL,
|
||||
`app_id` INT NOT NULL,
|
||||
`app_uid` CHAR(40) NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`source_env` VARCHAR(16) DEFAULT NULL,
|
||||
`source_origin` VARCHAR(2048) DEFAULT NULL,
|
||||
`email_sent` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_app_feedback_uid` (`uid`),
|
||||
KEY `idx_app_feedback_app_created` (`app_id`, `created_at`),
|
||||
KEY `idx_app_feedback_user_created` (`user_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065 / MySQL mysql_mig_20. Opt-in is the new `apps.feedback_enabled` column
|
||||
-- (developer-writable through the regular `puter.apps.update` path). Each row
|
||||
-- of `app_feedback` is one message a signed-in user submitted through the GUI
|
||||
-- feedback dialog; a copy is emailed to the app owner unless the per-app
|
||||
-- daily email cap suppressed it (`email_sent` records which). `app_uid` is
|
||||
-- denormalized alongside `app_id` so rows stay attributable after an app is
|
||||
-- deleted. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN IF NOT EXISTS feedback_enabled boolean NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_feedback (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uid CHAR(36) NOT NULL UNIQUE,
|
||||
app_id BIGINT NOT NULL,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_env VARCHAR(16) DEFAULT NULL,
|
||||
source_origin VARCHAR(2048) DEFAULT NULL,
|
||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON app_feedback (app_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON app_feedback (user_id, created_at);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- 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/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Opt-in is the new
|
||||
-- `apps.feedback_enabled` column (developer-writable through the regular
|
||||
-- `puter.apps.update` path; a dedicated column rather than a `metadata` key
|
||||
-- because Dev Center saves replace the whole metadata blob and would erase
|
||||
-- it). Each row of `app_feedback` is one message a signed-in user submitted
|
||||
-- through the GUI feedback dialog; a copy is emailed to the app owner unless
|
||||
-- the per-app daily email cap suppressed it (`email_sent` records which).
|
||||
-- `app_uid` is denormalized alongside `app_id` so rows stay attributable
|
||||
-- after an app is deleted (abuse forensics). `source_env` is 'app' (desktop
|
||||
-- dialog) or 'web' (puter.com popup opened from an external site);
|
||||
-- `source_origin` is the popup opener's browser-attested origin, null for
|
||||
-- desktop submissions. `created_at` is unix seconds. The (user_id,
|
||||
-- created_at) and (app_id, created_at) indexes serve the sliding-window
|
||||
-- rate-limit counts in AppFeedbackStore.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN "feedback_enabled" tinyint(1) DEFAULT '0';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"app_id" INTEGER NOT NULL,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"source_env" TEXT DEFAULT NULL, -- 'app' | 'web'
|
||||
"source_origin" TEXT DEFAULT NULL, -- attested opener origin (web popups)
|
||||
"email_sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON `app_feedback` (`app_id`, `created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON `app_feedback` (`user_id`, `created_at`);
|
||||
@@ -54,6 +54,26 @@ const NEVER_SENT_ERROR_CODES = new Set([
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Failures the server itself rolled back before returning. The statement did
|
||||
* reach the database, so this is not `NEVER_SENT`, but InnoDB guarantees it
|
||||
* left no effect — which makes a retry just as safe for writes.
|
||||
*
|
||||
* Only true for a _single_ statement: under autocommit each statement is its
|
||||
* own transaction, so "the transaction was rolled back" means "this statement
|
||||
* was rolled back". Retrying a multi-statement string on one of these would
|
||||
* re-run the statements that already committed ahead of the failure.
|
||||
*/
|
||||
const ROLLED_BACK_ERROR_CODES = new Set([
|
||||
// Deadlock — InnoDB picked this transaction as the victim and undid it.
|
||||
// MySQL's own message for it is "try restarting transaction".
|
||||
'ER_LOCK_DEADLOCK',
|
||||
// Lock wait timeout. Rolls back the statement rather than the transaction
|
||||
// unless innodb_rollback_on_timeout is set — the same thing when the
|
||||
// transaction is one statement.
|
||||
'ER_LOCK_WAIT_TIMEOUT',
|
||||
]);
|
||||
|
||||
const errorCode = (error: unknown): string | undefined =>
|
||||
(error as { code?: string } | null)?.code;
|
||||
|
||||
@@ -74,3 +94,13 @@ export const isNeverSentError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && NEVER_SENT_ERROR_CODES.has(code));
|
||||
};
|
||||
|
||||
/**
|
||||
* Lock-contention failures the server rolled back on its own. Safe to retry a
|
||||
* single statement on, writes included — see `ROLLED_BACK_ERROR_CODES` for the
|
||||
* one-statement precondition.
|
||||
*/
|
||||
export const isRolledBackError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && ROLLED_BACK_ERROR_CODES.has(code));
|
||||
};
|
||||
@@ -179,6 +179,29 @@ describe('EmailClient — template rendering', () => {
|
||||
expect(captured.html).toContain('<strong>424242</strong>');
|
||||
});
|
||||
|
||||
it('does not HTML-escape values in the plain-text subject header', async () => {
|
||||
const client = startClient();
|
||||
const captured: { subject?: string } = {};
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.subject = options.subject;
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'app-user-feedback', {
|
||||
owner_username: 'dev',
|
||||
sender_username: 'user',
|
||||
sender_email: null,
|
||||
app_title: "Bob's App & Games",
|
||||
app_name: 'bobs-app',
|
||||
app_link: 'https://puter.example/app/bobs-app',
|
||||
message: 'hi',
|
||||
});
|
||||
|
||||
// A subject is not HTML — entities would render literally in the
|
||||
// recipient's mail client.
|
||||
expect(captured.subject).toBe("New user feedback for Bob's App & Games");
|
||||
});
|
||||
|
||||
it('escapes html and converts newlines in nl2br values', async () => {
|
||||
const client = startClient();
|
||||
let html = '';
|
||||
|
||||
@@ -152,11 +152,16 @@ export class EmailClient extends PuterClient {
|
||||
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/** Render a template and send it to `to`. */
|
||||
/**
|
||||
* Render a template and send it to `to`. `options.replyTo` sets the
|
||||
* Reply-To header (e.g. so a recipient can respond to the originator of the
|
||||
* message rather than the no-reply From address).
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
@@ -168,6 +173,7 @@ export class EmailClient extends PuterClient {
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,7 +291,14 @@ export class EmailClient extends PuterClient {
|
||||
private compileTemplates(): void {
|
||||
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
|
||||
this.compiledTemplates[name as EmailTemplateName] = {
|
||||
subject: handlebars.compile(template.subject),
|
||||
// Subjects are plain-text headers: HTML-escaping would put
|
||||
// literal entities in front of the recipient (& etc.).
|
||||
// Header safety is handled elsewhere — the transport encodes
|
||||
// newlines, and free-form values (e.g. app_title) collapse
|
||||
// whitespace upstream.
|
||||
subject: handlebars.compile(template.subject, {
|
||||
noEscape: true,
|
||||
}),
|
||||
html: handlebars.compile(dedent(template.html)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,25 @@ Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
<blockquote>{{nl2br message}}</blockquote>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
'app-user-feedback': {
|
||||
subject: 'New user feedback for {{app_title}}',
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
</p>
|
||||
<blockquote>{{{nl2br message}}}</blockquote>
|
||||
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
|
||||
<p>
|
||||
You're receiving this because feedback is enabled for
|
||||
<a href="{{app_link}}">{{app_title}}</a> — manage it in the
|
||||
<a href="{{dev_center_link}}">Dev Center</a> under your app's settings.
|
||||
</p>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -214,6 +214,38 @@ describe('EventClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasListeners', () => {
|
||||
it('is false for a key nobody subscribed to', () => {
|
||||
expect(target.hasListeners(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for an exact subscriber', () => {
|
||||
target.on(key, vi.fn());
|
||||
expect(target.hasListeners(key)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when only a wildcard prefix matches', () => {
|
||||
target.on(`${key}.*`, vi.fn());
|
||||
expect(target.hasListeners(`${key}.child.deep`)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for an extension-registered listener', () => {
|
||||
extensionStore.events[key] = [vi.fn()];
|
||||
try {
|
||||
expect(target.hasListeners(key)).toBe(true);
|
||||
} finally {
|
||||
delete extensionStore.events[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('goes back to false once the last listener is removed', () => {
|
||||
const listener = vi.fn();
|
||||
target.on(key, listener);
|
||||
target.off(key, listener);
|
||||
expect(target.hasListeners(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension-registered listeners', () => {
|
||||
afterEach(() => {
|
||||
delete extensionStore.events[key];
|
||||
|
||||
@@ -129,6 +129,27 @@ export class EventClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything would run if `key` were emitted, wildcards included.
|
||||
*
|
||||
* For emitters whose work is only worth doing when someone is listening —
|
||||
* gathering the payload costs a round trip, say. Emitting into the void is
|
||||
* otherwise perfectly cheap and doesn't need this.
|
||||
*/
|
||||
hasListeners<T extends keyof EventMap>(key: T): boolean {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
if (this.#eventListeners[matchKey]?.length) return true;
|
||||
if (extensionStore.events[matchKey]?.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event by exact key OR a wildcard prefix.
|
||||
*
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import { Actor } from '../../core';
|
||||
import type { UsageInput } from '../../services/metering/types';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
|
||||
// GUI write events spread an entry plus per-event metadata into `response`.
|
||||
@@ -305,6 +306,38 @@ export type EventMap = {
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- Metering ----
|
||||
// Recurring charges are pure mechanism here: the metering service knows
|
||||
// when to ask (once per user per month, the first time that month's usage
|
||||
// record is touched) and how to record the answer, and an extension
|
||||
// decides what the account owes. Listeners push onto `charges`; the
|
||||
// service applies them as ordinary usage once every listener has run.
|
||||
// Nothing listening → no charges and no claim is ever taken.
|
||||
//
|
||||
// Emitted after the claim is settled, so a listener that calls back into
|
||||
// metering can't re-trigger it.
|
||||
'metering.monthly.charges': {
|
||||
/**
|
||||
* User-scoped, with no app on it: the account is what recurs, and the
|
||||
* app that happened to trigger the month's first call has nothing to do
|
||||
* with what is owed. Price against what the user owns.
|
||||
*/
|
||||
actor: Actor;
|
||||
/** The month being charged for, `YYYY-MM` in UTC. */
|
||||
month: string;
|
||||
/**
|
||||
* Push what the user owes here. Every listener's charges are merged
|
||||
* into one amount map and recorded as a single increment.
|
||||
*/
|
||||
charges: UsageInput[];
|
||||
};
|
||||
|
||||
// ---- Workers ----
|
||||
// Only a genuinely new worker. Redeploying an existing name updates its
|
||||
// row instead, and never reaches here — so a listener that prices this
|
||||
// is pricing workers that came into existence, not deploys.
|
||||
'worker.create': { actor: Actor; workerName: string };
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
cacheKey: string[];
|
||||
@@ -312,6 +345,15 @@ export type EventMap = {
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
* behind them were just written somewhere else.
|
||||
*
|
||||
* `outer.*` rather than `outer.pubsub.*` on purpose: the cache lives in the
|
||||
* Redis a cluster shares, so one node applying the invalidation covers the
|
||||
* whole cluster — fanning it out to siblings would just repeat the write.
|
||||
*/
|
||||
'outer.kv.cacheInvalidated': { cacheKeys: string[] };
|
||||
'outer.gui.item.added': GuiEvent;
|
||||
'outer.gui.item.updated': GuiEvent;
|
||||
'outer.gui.item.moved': GuiEvent;
|
||||
@@ -402,6 +444,22 @@ export type EventMap = {
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
/**
|
||||
* A user's subscription now resolves to a different policy. Carried on the
|
||||
* `outer.pubsub.*` channel so it reaches sibling nodes and peer clusters,
|
||||
* not just the one that handled the change — every node caches the resolved
|
||||
* policy, so a purchase is only live once they have all dropped theirs.
|
||||
*/
|
||||
'outer.pubsub.metering.subscription-changed': { userUuid: string };
|
||||
/**
|
||||
* A user's purchased credit balance changed. Separate from a policy change
|
||||
* because it moves the other half of the same budget, and carried on the
|
||||
* `outer.pubsub.*` channel for the same reason: every node caches whether
|
||||
* an account has budget left, so a top-up only lifts enforcement once they
|
||||
* have all dropped that answer.
|
||||
*/
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
HeadBucketCommand,
|
||||
PutObjectCommand,
|
||||
S3Client as AwsS3Client,
|
||||
type S3ClientConfig,
|
||||
@@ -244,6 +245,19 @@ export class S3Client extends PuterClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheapest round-trip that proves the object store answers for a bucket: no
|
||||
* object data, no listing, just a HEAD. Throws on any failure (missing
|
||||
* bucket, bad credentials, unreachable endpoint) so callers can treat it as
|
||||
* a liveness probe. Reuses the pooled per-region client.
|
||||
*/
|
||||
async headBucket(
|
||||
bucket = this.config.s3_bucket || LEGACY_STORAGE_BUCKET,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
await this.get(region).send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy storage migration
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -35,6 +35,39 @@ import DEFAULT_APP_ICON from './default-app-icon.js';
|
||||
* are just thin shape adapters that translate REST conventions into driver
|
||||
* calls.
|
||||
*/
|
||||
/**
|
||||
* Desktop boot reads the app list and individual app records repeatedly, so the
|
||||
* ceiling is set well above normal boot traffic and exists to catch a runaway
|
||||
* client rather than to pace one.
|
||||
*
|
||||
* "Repeatedly" is the operative word: an app record is read on launch, on
|
||||
* permission checks, and again by anything resolving an app by name, so these
|
||||
* accumulate against whatever else a session is doing rather than arriving on
|
||||
* their own. Sized for a session working hard, not for a person clicking.
|
||||
*/
|
||||
const APP_READ_LIMIT = {
|
||||
scope: 'app-read',
|
||||
limit: 1_800,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
|
||||
/**
|
||||
* Unauthenticated icon serving; no actor to key on, so the bucket is the
|
||||
* address — and an address is a NAT, a campus or a carrier gateway that can
|
||||
* hold hundreds of desktops. Each desktop boot pulls the taskbar's icons at
|
||||
* once, so a single burst from one network is already thousands of requests.
|
||||
* Responses are publicly cacheable and usually a redirect, so the ceiling is
|
||||
* not protecting bandwidth; it is there so a client looping on a broken icon
|
||||
* can't spin unbounded.
|
||||
*/
|
||||
const APP_ICON_LIMIT = {
|
||||
scope: 'app-icon',
|
||||
limit: 12_000,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
};
|
||||
|
||||
export class AppController extends PuterController {
|
||||
get appStore() {
|
||||
return this.stores.app;
|
||||
@@ -100,7 +133,9 @@ export class AppController extends PuterController {
|
||||
return d;
|
||||
}
|
||||
|
||||
registerRoutes(router) {
|
||||
registerRoutes(
|
||||
/** @type {import('../../core/http/PuterRouter').PuterRouter} */ router,
|
||||
) {
|
||||
// GET /apps — list apps owned by the current user
|
||||
router.get(
|
||||
'/apps',
|
||||
@@ -108,6 +143,7 @@ export class AppController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: APP_READ_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const apps = await this.appDriver.select({
|
||||
@@ -122,8 +158,16 @@ export class AppController extends PuterController {
|
||||
'/apps/nameAvailable',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
requireAuth: true,
|
||||
// Answers "does this name exist?" for any name, so it is a
|
||||
// name-enumeration oracle however cheap it is to serve.
|
||||
// Mirrors the `isNameAvailable` budget on AppDriver.
|
||||
rateLimit: {
|
||||
scope: 'app-name-available',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const name = req.query?.name;
|
||||
@@ -158,6 +202,7 @@ export class AppController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: APP_READ_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const actor = req.actor;
|
||||
@@ -216,6 +261,7 @@ export class AppController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: APP_READ_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const raw = req.params.name;
|
||||
@@ -289,6 +335,7 @@ export class AppController extends PuterController {
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: APP_READ_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const appList = Array.isArray(req.body) ? req.body : [];
|
||||
@@ -497,10 +544,14 @@ export class AppController extends PuterController {
|
||||
// Icons are <img src> targets from the GUI (root) AND resolved via
|
||||
// api_base_url in taskbar payloads. Register on both so either origin
|
||||
// works without a cross-subdomain redirect.
|
||||
router.get('/app-icon/:app_uid', { subdomain: ['api', ''] }, serveIcon);
|
||||
router.get(
|
||||
'/app-icon/:app_uid',
|
||||
{ subdomain: ['api', ''], rateLimit: APP_ICON_LIMIT },
|
||||
serveIcon,
|
||||
);
|
||||
router.get(
|
||||
'/app-icon/:app_uid/:size',
|
||||
{ subdomain: ['api', ''] },
|
||||
{ subdomain: ['api', ''], rateLimit: APP_ICON_LIMIT },
|
||||
serveIcon,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1018,3 +1018,25 @@ describe('AppController GET /app-icon remote icons', () => {
|
||||
expect(captured.headers['content-type']).toContain('image/svg+xml');
|
||||
});
|
||||
});
|
||||
|
||||
// ── app-icon rate limit ─────────────────────────────────────────────
|
||||
|
||||
describe('AppController app-icon rate limit', () => {
|
||||
it('sizes the icon bucket for a whole network rather than one desktop', () => {
|
||||
// Icons are unauthenticated `<img src>` targets, so the only key is
|
||||
// the address — which one NAT shares across every desktop behind it,
|
||||
// and each boot pulls the taskbar's icons in a burst.
|
||||
for (const path of ['/app-icon/:app_uid', '/app-icon/:app_uid/:size']) {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No GET ${path} route`);
|
||||
expect(route.options.rateLimit).toEqual({
|
||||
scope: 'app-icon',
|
||||
limit: 12_000,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import type { TokenSource } from '../../core/http/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { FS_READ_LIMIT } from '../fs/limits.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
@@ -1390,6 +1391,26 @@ describe('AuthController account-lifecycle route gating', () => {
|
||||
expect(opts.requireUserActor).toBe(true);
|
||||
});
|
||||
|
||||
// A fresh token is minted per protected mutation and nothing caches them,
|
||||
// so issuance has to outrun the COMBINED rate of everything that spends
|
||||
// one. The session-authenticated download path dominates — a multi-select
|
||||
// download spends a token per file — with logout and the handful of
|
||||
// session-management writes behind it.
|
||||
it('GET /get-anticsrf-token clears the budgets that consume tokens', () => {
|
||||
type Window = { limit: number; window: number };
|
||||
const issuance = routeOptions('get', '/get-anticsrf-token')
|
||||
.rateLimit as Window;
|
||||
const logout = routeOptions('post', '/logout').rateLimit as Window;
|
||||
|
||||
expect(issuance.window).toBe(60_000);
|
||||
expect(logout.window).toBe(60_000);
|
||||
expect(FS_READ_LIMIT.window).toBe(60_000);
|
||||
|
||||
expect(issuance.limit).toBeGreaterThan(
|
||||
FS_READ_LIMIT.limit + logout.limit,
|
||||
);
|
||||
});
|
||||
|
||||
it('requireUserActorGate rejects app-under-user and access-token actors', () => {
|
||||
const gate = requireUserActorGate();
|
||||
const run = (actor: Partial<Actor>) =>
|
||||
@@ -6907,3 +6928,253 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
expect(res.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Batch grant / revoke + cross-app data grants -----------------------
|
||||
|
||||
describe('AuthController — app-data grants', () => {
|
||||
let issuer: { id: number; username: string };
|
||||
let issuerActor: Actor;
|
||||
|
||||
beforeAll(async () => {
|
||||
const name = `ad_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: name,
|
||||
email: `${name}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
const row = await server.stores.user.getByUsername(name);
|
||||
await server.stores.user.update(row!.id, { email_confirmed: 1 });
|
||||
issuer = { id: row!.id, username: row!.username };
|
||||
issuerActor = {
|
||||
user: {
|
||||
id: row!.id,
|
||||
uuid: row!.uuid,
|
||||
username: row!.username,
|
||||
email: row!.email,
|
||||
email_confirmed: true,
|
||||
},
|
||||
} as Actor;
|
||||
});
|
||||
const makeAppRow = async (fields: Record<string, unknown> = {}) =>
|
||||
(await server.stores.app.create(
|
||||
{
|
||||
name: `ad-${uuidv4()}`,
|
||||
title: 'AppDataTest',
|
||||
index_url: 'https://example.test/index.html',
|
||||
...fields,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
)) as { id: number; uid: string };
|
||||
|
||||
const grantedPermissions = async (appUid: string): Promise<string[]> => {
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
|
||||
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
|
||||
'WHERE p.`user_id` = ? AND a.`uid` = ?',
|
||||
[issuer.id, appUid],
|
||||
)) as Array<{ permission: string }>;
|
||||
return rows.map((r) => r.permission);
|
||||
};
|
||||
|
||||
const post = (
|
||||
handler: 'handleGrantUserApp' | 'handleRevokeUserApp',
|
||||
body: Record<string, unknown>,
|
||||
) => {
|
||||
const res = makeRes();
|
||||
return inCtx(issuerActor, () =>
|
||||
controller[handler](makeReq(body, { actor: issuerActor }), res),
|
||||
).then(() => res);
|
||||
};
|
||||
|
||||
it('grants every permission in a list in one request', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
const permissions = [
|
||||
`app-data:${target.uid}:kv:read`,
|
||||
`app-data:${target.uid}:kv:delete`,
|
||||
];
|
||||
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions,
|
||||
});
|
||||
|
||||
expect(await grantedPermissions(grantee.uid)).toEqual(
|
||||
expect.arrayContaining(permissions),
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes every permission in a list in one request', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
const permissions = [
|
||||
`app-data:${target.uid}:kv:read`,
|
||||
`app-data:${target.uid}:fs:read`,
|
||||
];
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions,
|
||||
});
|
||||
|
||||
await post('handleRevokeUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions,
|
||||
});
|
||||
|
||||
const remaining = await grantedPermissions(grantee.uid);
|
||||
for (const permission of permissions) {
|
||||
expect(remaining).not.toContain(permission);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects the scalar and list forms together', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: `app-data:${target.uid}:kv:read`,
|
||||
permissions: [`app-data:${target.uid}:kv:write`],
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects an empty, oversized, or `*`-bearing list', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
for (const permissions of [
|
||||
[],
|
||||
Array.from(
|
||||
{ length: 17 },
|
||||
(_x, i) => `app-data:${target.uid}:kv:read${i}`,
|
||||
),
|
||||
[`app-data:${target.uid}:kv:read`, '*'],
|
||||
]) {
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes nothing when one entry in the list is invalid', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions: [
|
||||
`app-data:${target.uid}:kv:read`,
|
||||
`app-data:app-does-not-exist:kv:read`,
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
// The valid entry must not have landed: validation runs over the whole
|
||||
// list before any row is written.
|
||||
expect(await grantedPermissions(grantee.uid)).not.toContain(
|
||||
`app-data:${target.uid}:kv:read`,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes nothing when an entry is too wide for the column it lands in', async () => {
|
||||
// `#validateAppPermissionParams` allows 4096 chars but the column is 255,
|
||||
// so this passes shape validation and fails inside the grant. Before the
|
||||
// pre-flight, the first entry committed and the caller still got a 400 —
|
||||
// and the dialog reads a 4xx as "nothing was written".
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
const good = `app-data:${target.uid}:kv:read`;
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permissions: [good, 'x'.repeat(300)],
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await grantedPermissions(grantee.uid)).not.toContain(good);
|
||||
});
|
||||
|
||||
it('404s when the target app does not exist', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: 'app-data:app-nope/:kv:read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rejects a bare `app-data` with no target app', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
for (const permission of ['app-data', 'app-data:', 'app-data::kv']) {
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a grant when the target opted out of sharing', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const closed = await makeAppRow({
|
||||
metadata: JSON.stringify({ share_app_data: false }),
|
||||
});
|
||||
await expect(
|
||||
post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: `app-data:${closed.uid}:kv:read`,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('still allows revoking a grant after the target opts out', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
const permission = `app-data:${target.uid}:kv:read`;
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
});
|
||||
|
||||
await server.stores.app.update(target.id, {
|
||||
metadata: JSON.stringify({ share_app_data: false }),
|
||||
});
|
||||
|
||||
// A user must always be able to withdraw consent, whatever the target
|
||||
// now says about sharing.
|
||||
await post('handleRevokeUserApp', { app_uid: grantee.uid, permission });
|
||||
expect(await grantedPermissions(grantee.uid)).not.toContain(permission);
|
||||
});
|
||||
|
||||
it("creates the target's AppData directory for an fs grant", async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const target = await makeAppRow();
|
||||
const path = `/${issuer.username}/AppData/${target.uid}`;
|
||||
expect(await server.stores.fsEntry.getEntryByPath(path)).toBeFalsy();
|
||||
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission: `app-data:${target.uid}:fs:read`,
|
||||
});
|
||||
|
||||
// Without this the grant is valid but every read 404s until the target
|
||||
// app happens to run for the first time.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(path)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves unrelated permissions untouched by the new validation', async () => {
|
||||
const grantee = await makeAppRow();
|
||||
const permission = 'service:unrelated:ii:read';
|
||||
await post('handleGrantUserApp', {
|
||||
app_uid: grantee.uid,
|
||||
permission,
|
||||
});
|
||||
expect(await grantedPermissions(grantee.uid)).toContain(permission);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import type { HttpErrorOptions } from '../../core/http/HttpError.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
signStepUpToken,
|
||||
@@ -60,11 +61,19 @@ import {
|
||||
generateDefaultFsentries,
|
||||
promoteToVerifiedGroup,
|
||||
} from '../../util/userProvisioning.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const USERNAME_REGEX = /^\w{1,}$/;
|
||||
const USERNAME_MAX_LENGTH = 45;
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
||||
// crafted request from turning a single grant call into a bulk write.
|
||||
const MAX_PERMISSIONS_PER_REQUEST = 16;
|
||||
const DISPATCH_ID_MAX_LENGTH = 128;
|
||||
// Default SMS send attempts before the card fallback opens.
|
||||
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
|
||||
@@ -74,6 +83,94 @@ const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
|
||||
// crossed.
|
||||
const SEND_PHONE_RATE_LIMIT = 10;
|
||||
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
|
||||
|
||||
// -- Post-login route limits -----------------------------------------
|
||||
//
|
||||
// The credential legs above (login, signup, recovery, confirmation) each
|
||||
// carry their own limit. Everything a session can reach *after* signing
|
||||
// in shares the four shapes below, keyed on the actor rather than the
|
||||
// network — a per-account ceiling is the meaningful one once we know who
|
||||
// is calling.
|
||||
|
||||
/**
|
||||
* Mints or reconfigures a credential. Deliberately an hour-scale window: these
|
||||
* are human actions taken a handful of times, and an unbounded rate turns one
|
||||
* compromised session into a durable foothold.
|
||||
*/
|
||||
const CREDENTIAL_MINT_LIMIT = {
|
||||
scope: 'auth-credential-mint',
|
||||
limit: 20,
|
||||
window: 60 * 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Second-factor configuration, including the verify leg. Shorter window than
|
||||
* the mint limit because enabling 2FA legitimately involves a few attempts in a
|
||||
* row, but unbounded verification is a TOTP brute force.
|
||||
*/
|
||||
const TWO_FACTOR_LIMIT = {
|
||||
scope: 'auth-2fa-configure',
|
||||
limit: 30,
|
||||
window: 15 * 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/** Permission and membership writes. Never called in a loop by a client. */
|
||||
const GRANT_LIMIT = {
|
||||
scope: 'auth-grant',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Read-only checks the GUI makes on nearly every interaction. The ceiling is
|
||||
* high enough that only a runaway loop reaches it.
|
||||
*/
|
||||
const AUTH_CHECK_LIMIT = {
|
||||
scope: 'auth-check',
|
||||
limit: 300,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Anti-CSRF token issuance. Clients mint a fresh token per protected mutation
|
||||
* and cache nothing, so this ceiling has to clear the SUM of the budgets that
|
||||
* spend tokens — matching any single one of them guarantees the gate fires
|
||||
* before the mutation it guards does.
|
||||
*
|
||||
* What spends them: the session-authenticated download path, one token per
|
||||
* file, at the read budget of 600/min — a multi-selection download burns tokens
|
||||
* exactly the way a bulk delete burns its own budget, so this tracks the bulk
|
||||
* figure used for filesystem mutations; logout at 60/min; and the
|
||||
* session-management writes (revoke, rename), which a person triggers a handful
|
||||
* of times. Call it ~700/min of real demand, and leave enough on top that a
|
||||
* bulk operation runs out of files before it runs out of tokens.
|
||||
*/
|
||||
const ANTI_CSRF_MINT_LIMIT = {
|
||||
scope: 'anticsrf',
|
||||
limit: 1200,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/** Settings-page reads — enumerating sessions, permissions, groups. */
|
||||
const AUTH_LIST_LIMIT = {
|
||||
scope: 'auth-list',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/** Session plumbing: logout, GUI token, cookie sync. */
|
||||
const SESSION_LIMIT = {
|
||||
scope: 'auth-session',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
// Once the threshold is crossed the fallback stays open this long, so the
|
||||
// user can finish the card flow without racing the attempt counter's expiry.
|
||||
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
|
||||
@@ -1004,7 +1101,8 @@ export class AuthController extends PuterController {
|
||||
is_temp: user!.password === null && user!.email === null,
|
||||
ip:
|
||||
(req?.headers?.['x-forwarded-for'] as
|
||||
string | undefined) ||
|
||||
| string
|
||||
| undefined) ||
|
||||
(
|
||||
req as unknown as {
|
||||
connection?: { remoteAddress?: string };
|
||||
@@ -1040,6 +1138,7 @@ export class AuthController extends PuterController {
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
rateLimit: SESSION_LIMIT,
|
||||
})
|
||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||
// Clear the session cookie + `puter_token_v2`. Nothing issues the
|
||||
@@ -2703,7 +2802,25 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Captcha generation -------------------------------------------
|
||||
|
||||
@Get('/api/captcha/generate', { subdomain: '*' })
|
||||
@Get('/api/captcha/generate', {
|
||||
subdomain: '*',
|
||||
// Unauthenticated, renders an image per call, and is the gate
|
||||
// protecting /login and /signup — so bulk pre-generation is
|
||||
// directly useful to an attacker. Per-fingerprint for fairness on
|
||||
// shared IPs, plus a per-IP backstop against header rotation.
|
||||
//
|
||||
// The fingerprint bucket is the one sized for a person: a handful of
|
||||
// refreshes while getting a captcha right. The IP bucket is not — one
|
||||
// address is a whole office, campus or carrier gateway, and everyone
|
||||
// behind it is signing in through the same counter, so sizing it for
|
||||
// a browser would deny the captcha to a network rather than to an
|
||||
// attacker. It stays wide enough for that population and narrow
|
||||
// enough that header rotation still runs out.
|
||||
rateLimit: [
|
||||
{ scope: 'captcha', limit: 30, window: 60_000 },
|
||||
{ scope: 'captcha-ip', limit: 3_000, window: 60_000, key: 'ip' },
|
||||
],
|
||||
})
|
||||
async handleCaptchaGenerate(_req: Request, res: Response): Promise<void> {
|
||||
const difficulty =
|
||||
(this.config as { captcha?: { difficulty?: string } }).captcha
|
||||
@@ -2715,6 +2832,7 @@ export class AuthController extends PuterController {
|
||||
// -- Anti-CSRF token generation ----------------------------------
|
||||
|
||||
@Get('/get-anticsrf-token', {
|
||||
rateLimit: ANTI_CSRF_MINT_LIMIT,
|
||||
// Anti-CSRF tokens are only consumed by `requireUserActor` routes,
|
||||
// so issuance is scoped to the same actor kind for consistency.
|
||||
requireUserActor: true,
|
||||
@@ -2735,6 +2853,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/grant-user-user', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantUserUser(req: Request, res: Response): Promise<void> {
|
||||
const { target_username, permission, extra, meta } = req.body;
|
||||
@@ -2827,13 +2946,114 @@ export class AuthController extends PuterController {
|
||||
return app.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `permission` / `permissions` pair into the list to act on.
|
||||
*
|
||||
* One consent prompt can cover several scopes (read a store, write
|
||||
* another), and a client looping the single form would have to invent its
|
||||
* own partial-failure and rollback handling. Accepting the array keeps that
|
||||
* in one request.
|
||||
*/
|
||||
#appPermissionList(body: {
|
||||
permission?: unknown;
|
||||
permissions?: unknown;
|
||||
}): string[] {
|
||||
const { permission, permissions } = body;
|
||||
if (permissions !== undefined && permissions !== null) {
|
||||
if (permission !== undefined && permission !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Pass `permission` or `permissions`, not both',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(permissions) || permissions.length === 0) {
|
||||
throw new HttpError(400, 'Invalid `permissions`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (permissions.length > MAX_PERMISSIONS_PER_REQUEST) {
|
||||
throw new HttpError(400, 'Too many `permissions`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
for (const entry of permissions) {
|
||||
this.#validateAppPermissionParams({ permission: entry });
|
||||
// `*` means "revoke everything" in the scalar form only —
|
||||
// inside a list it would silently widen a targeted request.
|
||||
if (!entry || entry === '*') {
|
||||
throw new HttpError(400, 'Invalid `permissions`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...new Set(permissions as string[])];
|
||||
}
|
||||
return typeof permission === 'string' && permission ? [permission] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a cross-app data grant: the target must exist, must not have opted
|
||||
* out of sharing, and must be named. Also creates the target's AppData
|
||||
* directory for an `fs` scope, since it is only created lazily when the app
|
||||
* first runs — without this a valid grant would 404 until then.
|
||||
*/
|
||||
async #prepareAppDataGrant(
|
||||
actor: Actor,
|
||||
permission: string,
|
||||
): Promise<void> {
|
||||
const parsed = parseAppDataPermission(permission);
|
||||
if (!parsed) {
|
||||
// A bare `app-data` (or one with an empty target) would cover every
|
||||
// app the user has by prefix implication, which no prompt can
|
||||
// describe. Reject rather than treat it as an unrelated permission.
|
||||
if (
|
||||
permission === APP_DATA_PERMISSION_PREFIX ||
|
||||
permission.startsWith(`${APP_DATA_PERMISSION_PREFIX}:`)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Invalid `app-data` permission: missing target app',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await this.stores.app.getByUid(parsed.targetAppUid);
|
||||
if (!target) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
`entity_not_found: app:${parsed.targetAppUid}`,
|
||||
{ legacyCode: 'subject_does_not_exist' },
|
||||
);
|
||||
}
|
||||
if (!appDataSharingAllowed(target)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This app does not share its data with other apps',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const username = actor.user?.username;
|
||||
const userId = actor.user?.id;
|
||||
if ((parsed.store === 'fs' || !parsed.store) && username && userId) {
|
||||
await this.services.fs.mkdir(userId, {
|
||||
path: `/${username}/AppData/${parsed.targetAppUid}`,
|
||||
createMissingParents: true,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/auth/grant-user-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, extra, meta } = req.body;
|
||||
const { origin, permission, permissions, extra, meta } = req.body;
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
@@ -2841,27 +3061,43 @@ export class AuthController extends PuterController {
|
||||
extra,
|
||||
meta,
|
||||
});
|
||||
const list = this.#appPermissionList({ permission, permissions });
|
||||
if (origin) {
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
if (!app_uid || list.length === 0) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.services.permission.grantUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
extra ?? undefined,
|
||||
meta ?? undefined,
|
||||
);
|
||||
|
||||
// Validate every entry before writing any, so a bad one in the list
|
||||
// cannot leave a partially-granted set behind: the dialog reads a 4xx as
|
||||
// "nothing was written" and skips its withdrawal, so a partial commit
|
||||
// leaves live access the user was told they refused. The rewrite running
|
||||
// twice is cheaper than splitting the grant into prepare/commit.
|
||||
for (const entry of list) {
|
||||
await this.services.permission.assertUserAppPermissionWritable(
|
||||
entry,
|
||||
);
|
||||
await this.#prepareAppDataGrant(req.actor!, entry);
|
||||
}
|
||||
for (const entry of list) {
|
||||
await this.services.permission.grantUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
entry,
|
||||
extra ?? undefined,
|
||||
meta ?? undefined,
|
||||
);
|
||||
}
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@Post('/auth/grant-user-group', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantUserGroup(req: Request, res: Response): Promise<void> {
|
||||
const { group_uid, permission, extra, meta } = req.body;
|
||||
@@ -2890,6 +3126,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/revoke-user-user', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeUserUser(req: Request, res: Response): Promise<void> {
|
||||
const { target_username, permission, meta } = req.body;
|
||||
@@ -2912,24 +3149,28 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/revoke-user-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, meta } = req.body;
|
||||
const { origin, permission, permissions, meta } = req.body;
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
permission,
|
||||
meta,
|
||||
});
|
||||
const list = this.#appPermissionList({ permission, permissions });
|
||||
if (origin) {
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
if (!app_uid || list.length === 0) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// Deliberately not gated by the target's sharing flag: a user must
|
||||
// always be able to withdraw a grant, whatever the target now says.
|
||||
if (permission === '*') {
|
||||
await this.services.permission.revokeUserAppAll(
|
||||
req.actor!,
|
||||
@@ -2937,12 +3178,14 @@ export class AuthController extends PuterController {
|
||||
meta ?? undefined,
|
||||
);
|
||||
} else {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
meta ?? undefined,
|
||||
);
|
||||
for (const entry of list) {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
entry,
|
||||
meta ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
res.json({});
|
||||
}
|
||||
@@ -2950,6 +3193,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/revoke-user-group', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeUserGroup(req: Request, res: Response): Promise<void> {
|
||||
const { group_uid, permission, meta } = req.body;
|
||||
@@ -2969,7 +3213,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Permission checks -------------------------------------------
|
||||
|
||||
@Post('/auth/check-permissions', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/auth/check-permissions', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
||||
const { permissions } = req.body;
|
||||
if (!Array.isArray(permissions)) {
|
||||
@@ -2997,7 +3245,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Session management ------------------------------------------
|
||||
|
||||
@Get('/auth/list-sessions', { subdomain: 'api', requireUserActor: true })
|
||||
@Get('/auth/list-sessions', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: AUTH_LIST_LIMIT,
|
||||
})
|
||||
async handleListSessions(req: Request, res: Response): Promise<void> {
|
||||
const sessions = await this.services.auth.listSessions(req.actor!);
|
||||
res.json(sessions);
|
||||
@@ -3079,7 +3331,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Dev app permissions -----------------------------------------
|
||||
|
||||
@Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/auth/grant-dev-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantDevApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, extra, meta } = req.body;
|
||||
@@ -3112,6 +3368,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/revoke-dev-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeDevApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
@@ -3147,6 +3404,7 @@ export class AuthController extends PuterController {
|
||||
@Get('/auth/list-permissions', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: AUTH_LIST_LIMIT,
|
||||
})
|
||||
async handleListPermissions(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id;
|
||||
@@ -3211,7 +3469,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- App origin resolution ---------------------------------------
|
||||
|
||||
@Post('/auth/app-uid-from-origin', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/auth/app-uid-from-origin', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleAppUidFromOrigin(req: Request, res: Response): Promise<void> {
|
||||
const origin = req.body?.origin || req.query?.origin;
|
||||
if (!origin)
|
||||
@@ -3227,6 +3489,9 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/get-user-app-token', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
// Called once per app launch, and the GUI can legitimately launch
|
||||
// several in quick succession.
|
||||
rateLimit: { ...AUTH_CHECK_LIMIT, scope: 'app-token', limit: 120 },
|
||||
})
|
||||
async handleGetUserAppToken(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
@@ -3250,6 +3515,22 @@ export class AuthController extends PuterController {
|
||||
app = await this.stores.app.createFromOrigin(app_uid, origin, {
|
||||
ownerUserId,
|
||||
});
|
||||
// An origin's uid is a deterministic uuidv5, so a deleted app
|
||||
// reappears here under the identical uid. Withdraw any cross-app
|
||||
// data grants left pointing at it before this new row can inherit
|
||||
// consent the user gave its predecessor. Only *this* path can reuse
|
||||
// a uid: `AppStore.create` mints a random uuid4, which no deleted
|
||||
// app can ever hold again.
|
||||
//
|
||||
// Called directly rather than through `app.changed`: the token is
|
||||
// issued below, so this has to be able to stop that, and
|
||||
// `emitAndWait` swallows listener errors. Letting it throw is the
|
||||
// point — a sweep that failed leaves the old grants live against an
|
||||
// app whoever controls the origin now has just claimed.
|
||||
await this.services.appPermission.withdrawAppDataGrants(
|
||||
app_uid,
|
||||
'uid reused by a new app',
|
||||
);
|
||||
}
|
||||
if (!app) {
|
||||
throw new HttpError(404, `App ${app_uid} does not exist`, {
|
||||
@@ -3329,7 +3610,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ token, app_uid });
|
||||
}
|
||||
|
||||
@Post('/auth/check-app', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/auth/check-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin } = req.body;
|
||||
@@ -3368,6 +3653,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/create-access-token', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: CREDENTIAL_MINT_LIMIT,
|
||||
})
|
||||
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
|
||||
const { permissions, expiresIn, label } = req.body;
|
||||
@@ -3436,6 +3722,7 @@ export class AuthController extends PuterController {
|
||||
@Post('/auth/configure-2fa/:action', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: TWO_FACTOR_LIMIT,
|
||||
})
|
||||
async handleConfigure2fa(req: Request, res: Response): Promise<void> {
|
||||
const action = req.params.action;
|
||||
@@ -3566,7 +3853,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Developer profile -------------------------------------------
|
||||
|
||||
@Get('/get-dev-profile', { subdomain: 'api', requireUserActor: true })
|
||||
@Get('/get-dev-profile', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: AUTH_LIST_LIMIT,
|
||||
})
|
||||
async handleGetDevProfile(req: Request, res: Response): Promise<void> {
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
@@ -3596,7 +3887,13 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Group management --------------------------------------------
|
||||
|
||||
@Post('/group/create', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/group/create', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
// Creates a persistent row per call with no quota behind it, so it
|
||||
// sits on the hour-scale budget rather than the grant one.
|
||||
rateLimit: { ...CREDENTIAL_MINT_LIMIT, scope: 'group-create' },
|
||||
})
|
||||
async handleGroupCreate(req: Request, res: Response): Promise<void> {
|
||||
const extra = req.body.extra ?? {};
|
||||
const metadata = req.body.metadata ?? {};
|
||||
@@ -3617,7 +3914,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ uid });
|
||||
}
|
||||
|
||||
@Post('/group/add-users', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/group/add-users', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGroupAddUsers(req: Request, res: Response): Promise<void> {
|
||||
const { uid, users } = req.body ?? {};
|
||||
if (!uid)
|
||||
@@ -3649,7 +3950,11 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@Post('/group/remove-users', { subdomain: 'api', requireUserActor: true })
|
||||
@Post('/group/remove-users', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGroupRemoveUsers(req: Request, res: Response): Promise<void> {
|
||||
const { uid, users } = req.body ?? {};
|
||||
if (!uid)
|
||||
@@ -3681,7 +3986,11 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@Get('/group/list', { subdomain: 'api', requireUserActor: true })
|
||||
@Get('/group/list', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: AUTH_LIST_LIMIT,
|
||||
})
|
||||
async handleGroupList(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
const [owned, member] = await Promise.all([
|
||||
@@ -3694,7 +4003,22 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('/group/public-groups', { subdomain: 'api' })
|
||||
@Get('/group/public-groups', {
|
||||
subdomain: 'api',
|
||||
// The only unauthenticated route in the group set, so IP is the
|
||||
// only key available — and that makes the bucket an aggregate:
|
||||
// one office, campus or carrier gateway is a single key for
|
||||
// everybody behind it, and each of them reads this once while
|
||||
// bootstrapping. Sized for that population of real people rather
|
||||
// than one browser, and no wider: this sits next to the sign-in
|
||||
// surface, so it stays a real bound on enumeration.
|
||||
rateLimit: {
|
||||
scope: 'public-groups',
|
||||
limit: 1_200,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
})
|
||||
async handleGroupPublicGroups(_req: Request, res: Response): Promise<void> {
|
||||
res.json({
|
||||
user: this.config.default_user_group ?? null,
|
||||
@@ -3707,6 +4031,7 @@ export class AuthController extends PuterController {
|
||||
@Get('/get-gui-token', {
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: SESSION_LIMIT,
|
||||
})
|
||||
async handleGetGuiToken(req: Request, res: Response): Promise<void> {
|
||||
if (!req.actor?.session?.uid)
|
||||
@@ -3726,6 +4051,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
@Get('/session/sync-cookie', {
|
||||
rateLimit: SESSION_LIMIT,
|
||||
// Installs the session cookie. Only page script on our own origin
|
||||
// should be able to ask for that (the `tokenSource` check below is the
|
||||
// companion rule: the token has to come from an Authorization header,
|
||||
|
||||
@@ -32,6 +32,19 @@ const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
* - User-level: desktop background, taskbar items (UserStore)
|
||||
* - Folder-level: layout, sort_by/sort_order (fsentries table)
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — background, taskbar, layout, sort order. All four
|
||||
* persist to the user row on every call, and the GUI fires them on direct user
|
||||
* action, so a per-minute ceiling well above human speed is enough to catch a
|
||||
* stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
constructor(config, clients, stores, services) {
|
||||
super(config, clients, stores, services);
|
||||
@@ -53,6 +66,7 @@ export class DesktopController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { url, color, fit } = req.body ?? {};
|
||||
@@ -108,6 +122,7 @@ export class DesktopController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { items } = req.body ?? {};
|
||||
@@ -134,6 +149,7 @@ export class DesktopController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, layout } = req.body ?? {};
|
||||
@@ -161,6 +177,7 @@ export class DesktopController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, sort_by, sort_order } =
|
||||
|
||||
@@ -124,9 +124,9 @@ const makeSyntheticDriver = () => ({
|
||||
|
||||
const buildController = (driver: ReturnType<typeof makeSyntheticDriver>) => {
|
||||
// The controller reads `this.services?.permission` only when an actor
|
||||
// is on the request; otherwise the services bag is unused. The
|
||||
// alarm client is invoked on the rate-limit / concurrency rejection
|
||||
// paths, so stub a no-op `create`.
|
||||
// is on the request; otherwise the services bag is unused. The rejection
|
||||
// paths no longer alarm, but other paths still reach the client, so keep
|
||||
// a no-op `create` stubbed.
|
||||
const clients = { alarm: { create: () => {} } };
|
||||
return new DriverController(
|
||||
{} as any,
|
||||
|
||||
@@ -409,7 +409,7 @@ describe('DriverController per-method rate limiting', () => {
|
||||
configureRateLimit({ disabled: false } as never);
|
||||
});
|
||||
|
||||
it('answers 429 and raises a de-duped info alarm once the per-method budget is spent', async () => {
|
||||
it('answers 429 without alarming once the per-method budget is spent', async () => {
|
||||
const { handler, alarms, iface } = build({
|
||||
run: () => ({ ok: true }),
|
||||
rateLimit: { default: { limit: 1, window: 60_000 } },
|
||||
@@ -430,9 +430,8 @@ describe('DriverController per-method rate limiting', () => {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
expect(alarms).toContainEqual({
|
||||
id: 'driver_rate_limit_hit:rate-limited-iface:run',
|
||||
severity: 'info',
|
||||
});
|
||||
// Spending your own budget is the limit working as designed, so it
|
||||
// must not raise anything — the 429 is the whole signal.
|
||||
expect(alarms).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -158,11 +158,14 @@ interface MockRes {
|
||||
sentBody: string | undefined;
|
||||
contentType: string | undefined;
|
||||
pipedFrom: Readable | undefined;
|
||||
listeners: Record<string, Array<() => void>>;
|
||||
status(code: number): MockRes;
|
||||
json(body: unknown): MockRes;
|
||||
setHeader(key: string, value: string): MockRes;
|
||||
type(t: string): MockRes;
|
||||
send(body: string): MockRes;
|
||||
once(event: string, fn: () => void): MockRes;
|
||||
emit(event: string): void;
|
||||
}
|
||||
const makeRes = (): MockRes => {
|
||||
const res: MockRes = {
|
||||
@@ -172,6 +175,19 @@ const makeRes = (): MockRes => {
|
||||
sentBody: undefined,
|
||||
contentType: undefined,
|
||||
pipedFrom: undefined,
|
||||
// `#handleCall` releases a driver's concurrency slot on `finish` /
|
||||
// `close`, so the stub has to behave like an emitter for any driver
|
||||
// that declares a `concurrent` policy.
|
||||
listeners: {},
|
||||
once(event: string, fn: () => void) {
|
||||
(this.listeners[event] ??= []).push(fn);
|
||||
return this;
|
||||
},
|
||||
emit(event: string) {
|
||||
const fns = this.listeners[event] ?? [];
|
||||
this.listeners[event] = [];
|
||||
for (const fn of fns) fn();
|
||||
},
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
|
||||
@@ -45,6 +45,27 @@ import { PuterController } from '../types.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Coarse envelope over the whole `/call` surface, so that spreading calls
|
||||
* across many interfaces can't dodge every individual bucket. Per-driver limits
|
||||
* are what actually shape traffic.
|
||||
*
|
||||
* "Coarse" is a constraint, not a description: for this to be an envelope it
|
||||
* has to sit _above_ every per-driver budget, or it silently becomes the real
|
||||
* limit for the widest ones and overrides the tier policy they declare.
|
||||
* `driverPolicies.test.ts` asserts that ordering against every registered
|
||||
* driver, so raising a driver's budget past this number fails there rather than
|
||||
* in production. The headroom above the widest driver (notifications, at
|
||||
* 3000/30s) is what leaves room for one caller to be busy on two interfaces at
|
||||
* once.
|
||||
*/
|
||||
export const DRIVERS_CALL_LIMIT = {
|
||||
scope: 'drivers-call',
|
||||
limit: 8000,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
|
||||
// Every driver call is timed here already, for the lifecycle events below.
|
||||
// Recording the same number as a histogram makes the per-interface latency
|
||||
// distribution available downstream; which interfaces are worth keeping is a
|
||||
@@ -180,12 +201,26 @@ export class DriverController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
router.post(
|
||||
'/call',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: DRIVERS_CALL_LIMIT,
|
||||
},
|
||||
this.#handleCall,
|
||||
);
|
||||
router.get(
|
||||
'/list-interfaces',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
// Static introspection output, read once at boot.
|
||||
rateLimit: {
|
||||
scope: 'drivers-list-interfaces',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
this.#handleListInterfaces,
|
||||
);
|
||||
}
|
||||
@@ -305,19 +340,8 @@ export class DriverController extends PuterController {
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
) {
|
||||
// De-dupe on (iface, method) so a hot loop across many users
|
||||
// aggregates as occurrences on a single low-severity alarm
|
||||
// instead of fanning out one per user.
|
||||
this.clients.alarm.create(
|
||||
`driver_rate_limit_hit:${ifaceName}:${method}`,
|
||||
`Driver rate limit hit on ${ifaceName}:${method}`,
|
||||
{
|
||||
iface: ifaceName,
|
||||
method,
|
||||
userUuid: req.actor?.user?.uuid,
|
||||
},
|
||||
'info',
|
||||
);
|
||||
// Deliberately unalarmed: a caller spending its own budget is
|
||||
// the limit working, not an incident. The 429 is the signal.
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
@@ -340,16 +364,8 @@ export class DriverController extends PuterController {
|
||||
concurrentSpec,
|
||||
);
|
||||
if (!handle.ok) {
|
||||
this.clients.alarm.create(
|
||||
`driver_concurrent_limit_hit:${ifaceName}:${method}`,
|
||||
`Driver concurrency limit hit on ${ifaceName}:${method}`,
|
||||
{
|
||||
iface: ifaceName,
|
||||
method,
|
||||
userUuid: req.actor?.user?.uuid,
|
||||
},
|
||||
'info',
|
||||
);
|
||||
// Unalarmed for the same reason as the rate-limit rejection
|
||||
// above: hitting a declared cap is the cap doing its job.
|
||||
throw new HttpError(429, 'Too many concurrent requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* 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 { Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
// Boots one real PuterServer (in-memory sqlite + mocked externals) and
|
||||
// registers AppFeedbackController's decorated routes onto a fresh
|
||||
// PuterRouter. Tests drive the captured handlers with stub req/res; the
|
||||
// stores/services underneath are the live wired ones, so rows land in the
|
||||
// real `app_feedback` table.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.appFeedback.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const makeApp = async (
|
||||
ownerUserId: number,
|
||||
opts: { feedbackEnabled?: boolean; indexUrl?: string; name?: string } = {},
|
||||
) => {
|
||||
const name =
|
||||
opts.name ?? `fdbk-app-${Math.random().toString(36).slice(2, 10)}`;
|
||||
return await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: `Feedback Test ${name}`,
|
||||
index_url: opts.indexUrl ?? `https://${name}.example.com`,
|
||||
...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}),
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
};
|
||||
|
||||
// Feedback is only offered when the deployment can deliver it (email
|
||||
// transport configured); most tests want that baseline without asserting
|
||||
// anything about the mail itself.
|
||||
const mockEmailConfigured = () =>
|
||||
vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const confirmOwnerEmail = async (userId: number) => {
|
||||
await server.clients.db.write(
|
||||
'UPDATE `user` SET `email_confirmed` = ? WHERE `id` = ?',
|
||||
[server.clients.db.booleanValue(true), userId],
|
||||
);
|
||||
const user = await server.stores.user.getById(userId);
|
||||
if (user) await server.stores.user.invalidate(user);
|
||||
};
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: { statusCode: number; body: unknown } = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findRoute = (method: string, path: string) => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler: RequestHandler = findRoute(method, path).handler;
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
const submit = (actor: Actor, body: unknown) => {
|
||||
const { res, captured } = makeRes();
|
||||
return callRoute('post', '/', makeReq({ body, actor }), res).then(
|
||||
() => captured,
|
||||
);
|
||||
};
|
||||
|
||||
// ── Route gates ─────────────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController route options', () => {
|
||||
it('rejects app actors and cross-origin pages on submit', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
// requireUserActor is what makes feedback impossible to submit
|
||||
// programmatically with an app token; guiOriginOnly keeps
|
||||
// cross-origin browser pages out even with a leaked user token.
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
expect(options.guiOriginOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('stacks a per-user budget with a per-IP backstop', () => {
|
||||
const { options } = findRoute('post', '/');
|
||||
const limits = options.rateLimit;
|
||||
expect(Array.isArray(limits)).toBe(true);
|
||||
const keys = (limits as Array<{ key?: unknown }>).map((l) => l.key);
|
||||
expect(keys).toContain('user');
|
||||
expect(keys).toContain('ip');
|
||||
});
|
||||
|
||||
it('requires a user actor on the target pre-flight too', () => {
|
||||
const { options } = findRoute('get', '/target');
|
||||
expect(options.requireUserActor).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /app-feedback/target ────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController GET /target', () => {
|
||||
it('throws 400 when neither or both of app/origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
for (const query of [
|
||||
{},
|
||||
{ app: 'x', origin: 'https://x.example.com' },
|
||||
]) {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute('get', '/target', makeReq({ query, actor }), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports enabled:false for an unknown app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: 'no-such-app-xyz' }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an app that has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:true with canonical title/name for an opted-in app', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.uid }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an opted-in app whose name starts with "app-"', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const name = `app-fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true, name });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an origin to the app whose index_url it matches', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = new URL(app.index_url).origin;
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { origin }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
enabled: true,
|
||||
app: { name: app.name, title: app.title },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports enabled:false when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): this is the self-hosted no-SMTP default.
|
||||
// Feedback that can never be delivered must not be solicited.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({ query: { app: app.name }, actor }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toMatchObject({ enabled: false });
|
||||
});
|
||||
|
||||
it('reports enabled:false for an origin with no registered app', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/target',
|
||||
makeReq({
|
||||
query: { origin: 'https://nobody-registered.example.com' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ enabled: false, app: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /app-feedback ──────────────────────────────────────────────
|
||||
|
||||
describe('AppFeedbackController POST /', () => {
|
||||
it('throws 400 when message is missing or not a string', async () => {
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
for (const message of [undefined, 12345, '']) {
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws 400 when both app and origin are given', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: 'x',
|
||||
origin: 'https://x.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when the origin exceeds the stored column size', async () => {
|
||||
// source_origin is VARCHAR(2048) on MySQL/Postgres; a longer origin
|
||||
// must be rejected up front, not fail (or silently truncate) at the
|
||||
// INSERT after passing every other validation.
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const origin = `${new URL(app.index_url).origin}/${'x'.repeat(2500)}`;
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { origin, message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the app has not opted in', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId);
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'hi there' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 403 for an unknown app and an unknown origin alike', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: 'no-such-app-xyz', message: 'hi' }),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
await expect(
|
||||
submit(actor, {
|
||||
origin: 'https://nobody-registered.example.com',
|
||||
message: 'hi',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('throws 403 feedback_not_enabled when the email transport is unconfigured', async () => {
|
||||
// No mockEmailConfigured(): the opted-in app must still refuse — a
|
||||
// stored row nothing can read, sold to the sender as delivered, is
|
||||
// worse than an honest refusal.
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, { app: app.name, message: 'into the void' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'feedback_not_enabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 400 when the message exceeds the length limit', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
submit(actor, {
|
||||
app: app.name,
|
||||
message: 'x'.repeat(
|
||||
AppFeedbackService.MESSAGE_MAX_LENGTH + 1,
|
||||
),
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('stores a normalized row and responds with an empty object', async () => {
|
||||
mockEmailConfigured();
|
||||
const { userId: ownerId } = await makeUser();
|
||||
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
||||
const { actor, userId } = await makeUser();
|
||||
const captured = await submit(actor, {
|
||||
app: app.uid,
|
||||
message: ' Great\r\napp! | ||||