mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908ec23eda | ||
|
|
d5ae5a0049 | ||
|
|
419d0aaa89 | ||
|
|
c3a0c28705 | ||
|
|
7ceb2090b7 | ||
|
|
ea63c3093e | ||
|
|
e8236a100c | ||
|
|
20b3b88e39 | ||
|
|
cbae39a08f | ||
|
|
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.
|
||||
|
||||
+39
-35
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
- **`puter.com`**
|
||||
- **`api.puter.com`**
|
||||
|
||||
Out-of-scope:
|
||||
|
||||
* Third-party services, applications, or libraries not maintained by Puter.
|
||||
* Social engineering attacks (e.g., phishing against staff).
|
||||
* Denial of Service (DoS), spam, or volumetric attacks.
|
||||
* Physical security issues.
|
||||
- Third-party services, applications, or libraries not maintained by Puter.
|
||||
- Social engineering attacks (e.g., phishing against staff).
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
|
||||
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
* **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
|
||||
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
|
||||
|
||||
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
|
||||
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
@@ -51,26 +51,30 @@ Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
## Proof of Reproduction
|
||||
|
||||
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
|
||||
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
* The **observed** result versus the **expected** result.
|
||||
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
* For server-side bugs: a runnable proof-of-concept.
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
Include:
|
||||
|
||||
* A description of the vulnerability
|
||||
* Steps to reproduce
|
||||
* Potential impact
|
||||
* Suggested remediation (if available)
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
@@ -78,10 +82,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
|
||||
|
||||
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
|
||||
|
||||
* **Critical: \$1,000 – \$2,000**
|
||||
* **High: \$500 – \$1,000**
|
||||
* **Medium: \$200 – \$500**
|
||||
* **Low: \$50 – \$100**
|
||||
- **Critical: \$1,000 – \$2,000**
|
||||
- **High: \$500 – \$1,000**
|
||||
- **Medium: \$200 – \$500**
|
||||
- **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
|
||||
+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();
|
||||
});
|
||||
@@ -201,6 +206,84 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('never exposes phone, card fingerprint or signup identity', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
phone: '+15551234567',
|
||||
card_fingerprint: 'fp_ABC123',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
// Guard against a vacuous assertion below: the columns really do hold
|
||||
// the values we then expect never to see on the wire.
|
||||
const stored = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(stored?.phone).toBe('+15551234567');
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
expect(body.card_fingerprint).toBeUndefined();
|
||||
expect(body.password).toBeUndefined();
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
// The verification flags, which the GUI acts on, still ship.
|
||||
expect(body).toHaveProperty('requires_phone_verification');
|
||||
expect(body).toHaveProperty('requires_card_verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Nested sensitive keys are removed too; siblings survive.
|
||||
expect(metadata.billing).toEqual({ tier: 'pro' });
|
||||
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
|
||||
|
||||
// The scrub works on a copy — server-side state is untouched.
|
||||
const fresh = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
|
||||
expect(
|
||||
(fresh?.metadata?.billing as Record<string, unknown>)
|
||||
?.card_fingerprint,
|
||||
).toBe('fp_NESTED');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+107
-14
@@ -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
|
||||
@@ -24,6 +33,68 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
// Keys that must never leave the server, whoever put them on the response.
|
||||
// `details` is an explicit pick, but the `whoami.details` event hands
|
||||
// listeners the full UserRow next to the object they may write to, and
|
||||
// `metadata` is a free-form blob — so any of these can arrive on the
|
||||
// response without an edit to the pick above. The scrub runs last, over the
|
||||
// whole payload, and is the one place that decides what "sensitive" means.
|
||||
//
|
||||
// Credentials and single-use tokens, the payment/phone identifiers used for
|
||||
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
|
||||
// card number), the network identity recorded at signup, and internal
|
||||
// anti-abuse bookkeeping. `requires_phone_verification` /
|
||||
// `requires_card_verification` stay: they are the flags the GUI acts on, and
|
||||
// they carry no identifier.
|
||||
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'password',
|
||||
'tmp_password',
|
||||
'pass_recovery_token',
|
||||
'email_confirm_code',
|
||||
'email_confirm_token',
|
||||
'change_email_confirm_token',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
'card_fingerprint',
|
||||
'phone',
|
||||
'clean_email',
|
||||
'signup_ip',
|
||||
'signup_ip_forwarded',
|
||||
'signup_user_agent',
|
||||
'signup_origin',
|
||||
'signup_server',
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
value: unknown,
|
||||
seen: Set<object> = new Set(),
|
||||
depth = 0,
|
||||
): void => {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
delete (value as Record<string, unknown>)[key];
|
||||
continue;
|
||||
}
|
||||
scrubSensitive(
|
||||
(value as Record<string, unknown>)[key],
|
||||
seen,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -65,8 +136,12 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
// Deep-copied (it is decoded JSON) so the scrub below edits the response
|
||||
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
|
||||
// anything else on the denylist — are removed by scrubSensitive.
|
||||
const metadata = user.metadata
|
||||
? structuredClone(user.metadata)
|
||||
: user.metadata;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
@@ -75,7 +150,9 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
phone: user.phone,
|
||||
// The phone number itself is deliberately absent: nothing on the
|
||||
// client reads it, and it is PII that would otherwise be handed to
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -98,6 +175,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 +219,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 +237,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,19 +257,38 @@ 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;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
// Last word on what ships, after every listener has had its say.
|
||||
scrubSensitive(details);
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -1040,6 +1137,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 +2801,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 +2831,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 +2852,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 +2945,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 +3060,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 +3125,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 +3148,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 +3177,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 +3192,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 +3212,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 +3244,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 +3330,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 +3367,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 +3403,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 +3468,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 +3488,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 +3514,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 +3609,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 +3652,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 +3721,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 +3852,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 +3886,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 +3913,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 +3949,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 +3985,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 +4002,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 +4030,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 +4050,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;
|
||||
@@ -196,6 +212,30 @@ const makeRes = (): MockRes => {
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the call route and return the rejection, or null when it resolves.
|
||||
* Lets a test assert on *which* gate rejected without depending on how far
|
||||
* an admitted call gets afterwards.
|
||||
*/
|
||||
const callForError = async (
|
||||
routes: Captured,
|
||||
req: Request,
|
||||
actor: Actor,
|
||||
): Promise<{ statusCode?: number; legacyCode?: string } | null> => {
|
||||
try {
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e as { statusCode?: number; legacyCode?: string };
|
||||
}
|
||||
};
|
||||
|
||||
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
|
||||
({
|
||||
body,
|
||||
@@ -322,8 +362,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
|
||||
// Privileged ("godmode") apps call AI with the user's own session
|
||||
// token, so the AI drivers no longer set `noUserSession`: the
|
||||
// session actor reaches the permission scan (403 forbidden — no
|
||||
// perms granted) instead of the credential-shape rejection.
|
||||
// session actor must get past the credential-shape gate.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -333,18 +372,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
|
||||
@@ -369,29 +398,15 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
// Fresh actors hold no service:* perms, so the call still
|
||||
// 403s — but at the permission scan, proving the
|
||||
// credential-shape gate let the delegated actor through.
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a user-scoped worker session on the AI drivers', async () => {
|
||||
// Workers deployed without an app binding authenticate as a user
|
||||
// actor whose session row is kind='worker' — they must keep their
|
||||
// AI access (they fail later on permission here, not on
|
||||
// credential shape).
|
||||
// AI access rather than be rejected for their credential shape.
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
@@ -405,24 +420,13 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
|
||||
// puter-kvstore doesn't set the flag; the session actor reaches
|
||||
// the permission scan (403 forbidden — no perms granted) instead
|
||||
// of being rejected for its credential shape.
|
||||
// puter-kvstore doesn't set the flag, so the session actor must get
|
||||
// past the credential-shape gate.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -432,24 +436,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('rejects with 403 when the actor lacks the service permission', async () => {
|
||||
// A fresh user has no service:* perms → permission.check returns
|
||||
// false → 403.
|
||||
const actor = await makeUserActor();
|
||||
// A scoped access token holds only the permissions written to its
|
||||
// own row, so it is the actor shape that can still be denied — the
|
||||
// `driver`/`service` grants every user inherits are resolved from
|
||||
// the issuer only for full-access tokens.
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: base,
|
||||
fullAccess: false,
|
||||
},
|
||||
};
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
@@ -458,15 +462,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err).toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('returns the wrapped {success, result, service} envelope on a successful call', async () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -262,8 +297,7 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
| PermissionService
|
||||
| undefined;
|
||||
PermissionService | undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
@@ -305,19 +339,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 +363,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! | ||||