mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bc8d6d6f | ||
|
|
4700c2dc65 | ||
|
|
e041fb6b9b | ||
|
|
5721bdeb1d | ||
|
|
132cae9f6a | ||
|
|
4a9e5a2440 | ||
|
|
600064dcee | ||
|
|
66ee50317c | ||
|
|
8f0a317a97 | ||
|
|
8c816060a3 | ||
|
|
0131ebfe9b | ||
|
|
2cb7516141 | ||
|
|
0c5c1be777 | ||
|
|
8ecaa77a0e | ||
|
|
07a11240ea | ||
|
|
7758aea78c | ||
|
|
83de67cca9 | ||
|
|
9f69483593 | ||
|
|
606f5a27a3 | ||
|
|
58ed2f485f | ||
|
|
4c3c684cb4 | ||
|
|
438a4584de | ||
|
|
b3fda186e7 | ||
|
|
bc0f4dc279 | ||
|
|
17f5454265 | ||
|
|
5ddff2b3d6 | ||
|
|
52f0176854 | ||
|
|
300fb115a1 | ||
|
|
6debd7df76 | ||
|
|
70404579f2 | ||
|
|
7462a8eda9 | ||
|
|
718de8a9c6 | ||
|
|
8abdcf4f7f | ||
|
|
476f7e075d | ||
|
|
1bc8cb964e | ||
|
|
2690f4934c | ||
|
|
d07ddeaaf5 | ||
|
|
1261976088 | ||
|
|
2abd5533ac | ||
|
|
922d203e18 | ||
|
|
027e9a71f3 | ||
|
|
259cb69fde | ||
|
|
081941a6f3 | ||
|
|
e2a5ab8cdc | ||
|
|
07e889f135 | ||
|
|
ab7a5f325a | ||
|
|
b4b464554b | ||
|
|
4de1d1fb39 | ||
|
|
4e9b03bf5c | ||
|
|
a63172e9ae | ||
|
|
29d805d793 | ||
|
|
059fae3d15 | ||
|
|
18ada4764a | ||
|
|
72203152d9 | ||
|
|
cec8382d4d | ||
|
|
3f335939b3 | ||
|
|
4e821c128c | ||
|
|
c313a381b6 | ||
|
|
76e0d2ec93 | ||
|
|
24fb679dd3 | ||
|
|
eb9f03e984 | ||
|
|
9292771554 | ||
|
|
f4974581d0 | ||
|
|
b39bb771d9 | ||
|
|
766379ae9f | ||
|
|
0be3bc55c2 | ||
|
|
d1484af754 | ||
|
|
dfd7925872 | ||
|
|
e50d210423 | ||
|
|
51ce868d1e | ||
|
|
461cb7d0a9 | ||
|
|
bb97660ad7 | ||
|
|
3e3d02bafe | ||
|
|
b2fef45fd6 | ||
|
|
728966bfb1 | ||
|
|
3f7ee34706 | ||
|
|
ff333d05e8 | ||
|
|
194ca7c789 | ||
|
|
0b85203edb | ||
|
|
23c33b2e06 | ||
|
|
1b6334c02c | ||
|
|
0593844acf | ||
|
|
67d048148c | ||
|
|
05cbd33211 | ||
|
|
5c8f02384c | ||
|
|
5d20e054ac | ||
|
|
3bd3955e0d | ||
|
|
79c856386f | ||
|
|
59f73528cb | ||
|
|
27e858d760 | ||
|
|
0bd6458441 | ||
|
|
45aff36f0c | ||
|
|
a1f7577acb | ||
|
|
af6547724d | ||
|
|
e9922aea26 | ||
|
|
b136c56cb5 | ||
|
|
aadcda073d | ||
|
|
9da03555ae | ||
|
|
f428797a79 | ||
|
|
ad3d15e7e2 | ||
|
|
07bf0be9c7 | ||
|
|
d116f49669 | ||
|
|
a5f267c2d4 | ||
|
|
2fdd67c72e | ||
|
|
92dfe4019e | ||
|
|
61a6820767 | ||
|
|
38b131e5e8 | ||
|
|
230241d6d2 | ||
|
|
577693bf84 | ||
|
|
27dd0f8ce6 | ||
|
|
50d3794283 | ||
|
|
7fdbc0247f | ||
|
|
3bf5ba3525 | ||
|
|
018196f8b4 | ||
|
|
c3e2717e8e | ||
|
|
15527eac4c | ||
|
|
4a23c296f0 | ||
|
|
790db87e13 | ||
|
|
b901040c14 | ||
|
|
ed7e0b9cc4 | ||
|
|
883072d144 | ||
|
|
8d90418c9e | ||
|
|
de74b0c488 | ||
|
|
967b974fe9 | ||
|
|
93885dfc54 | ||
|
|
ab077f047b | ||
|
|
63f857ce3c | ||
|
|
480d6bc3b5 | ||
|
|
1efe086565 | ||
|
|
06bdc26efd | ||
|
|
c2a975b3f7 | ||
|
|
b8b0650990 | ||
|
|
bafe8af71b | ||
|
|
187e4c8a2f | ||
|
|
f2c9737bd8 | ||
|
|
5fbdc7cac5 | ||
|
|
3d2bdfceef | ||
|
|
2083ef47c0 | ||
|
|
9940beb4bb | ||
|
|
fa36f2e3cf | ||
|
|
efe6ef09bd | ||
|
|
367d20a55c | ||
|
|
e886e86cb5 | ||
|
|
106978e714 | ||
|
|
fb74789de1 | ||
|
|
a983969f32 | ||
|
|
b19b312e30 |
No files matched your search
+45
-45
@@ -6,56 +6,56 @@
|
||||
/src/gui/ @jelveh @Salazareo
|
||||
|
||||
# Documentation, developer site, and CLI
|
||||
/doc/ @reynaldichernando
|
||||
/src/docs/ @reynaldichernando
|
||||
/src/dev-center/ @reynaldichernando
|
||||
/src/cli/ @reynaldichernando
|
||||
/doc/ @reynaldichernando @Salazareo
|
||||
/src/docs/ @reynaldichernando @Salazareo
|
||||
/src/dev-center/ @reynaldichernando @Salazareo
|
||||
/src/cli/ @reynaldichernando @Salazareo
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland
|
||||
/src/worker/ @ProgrammerIn-wonderland
|
||||
/src/worker-types/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: sharing, permissions, and teams
|
||||
/src/backend/controllers/share/ @jfcastro92
|
||||
/src/backend/controllers/team/ @jfcastro92
|
||||
/src/backend/services/share/ @jfcastro92
|
||||
/src/backend/services/permission/ @jfcastro92
|
||||
/src/backend/services/acl/ @jfcastro92
|
||||
/src/backend/services/team/ @jfcastro92
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92
|
||||
/src/backend/stores/share/ @jfcastro92
|
||||
/src/backend/stores/permission/ @jfcastro92
|
||||
/src/backend/stores/group/ @jfcastro92
|
||||
/src/backend/stores/team/ @jfcastro92
|
||||
/src/backend/controllers/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/controllers/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/acl/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
|
||||
/src/backend/stores/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/group/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/team/ @jfcastro92 @Salazareo
|
||||
|
||||
# SDK: AI, hosting, workers, and networking
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# SDK: sharing and permissions
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
git reset --hard main
|
||||
- name: Create/Update PR
|
||||
if: steps.find-pr.outputs.number == ''
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: Update release branch
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -72,3 +72,6 @@ servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
|
||||
# Local build override
|
||||
docker-compose.override.yml
|
||||
@@ -58,6 +58,36 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
|
||||
---
|
||||
|
||||
## Local development configuration
|
||||
|
||||
To test a local Docker build, create `docker-compose.override.yml` in the repository
|
||||
root (or merge into the existing file):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
Compose merges it automatically. Run `docker compose up -d --build` to build and
|
||||
start the stack, and repeat after source changes. Keep local build settings in
|
||||
this gitignored override instead of editing `docker-compose.yml`, so they stay
|
||||
out of pull requests and do not conflict when pulling updates. See
|
||||
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
|
||||
for the required runtime configuration.
|
||||
|
||||
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
|
||||
when budget checks block unrelated tests; no additional approval is needed.
|
||||
Merge it into the ignored `config.json` for `npm start` or
|
||||
`puter/config/config.json` for Docker, then restart Puter. It applies to all
|
||||
accounts on that local instance, including guests, and usage is still recorded.
|
||||
Keep normal settings when testing budget or subscription enforcement, and leave
|
||||
shared defaults unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
+10
-3
@@ -21,12 +21,19 @@ Out-of-scope:
|
||||
|
||||
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.
|
||||
- **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 out of scope. This explicitly includes **missing `Content-Security-Policy: sandbox` on any endpoint that serves a file inline** — `/token-read`, `/file`, `/fs/read`, WebDAV, and any MIME type the `applyInlineContentSecurity` allow-list does not cover (`text/xsl`, `image/svg+xml`, `application/xhtml+xml`, …). Script execution on the API origin has nothing to reach: there is no session cookie, no `localStorage`, and no ambient credential there. WebDAV additionally has to return an accurate MIME type for real WebDAV clients. The one exception we _do_ evaluate on its merits is anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host — if your report does not name a specific credential that is actually reachable from the API origin, it is this non-issue.
|
||||
- **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.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
|
||||
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
|
||||
- **Concurrency / TOCTOU overspend of credits or storage quota.** Firing concurrent requests to exceed an AI credit hold or a storage quota by some margin is known and accepted; the per-account concurrency and rate-limit gates are the control we rely on, and the residual overage is bounded and billed. Report these only if you can demonstrate _unbounded_ overspend.
|
||||
- **Session and credential lifecycle around 2FA enrollment.** Enrolling in 2FA is not a credential-revocation event: existing authenticated sessions stay valid, and outstanding password-reset links stay valid. Likewise, account-management actions behind the `/user-protected/*` gate are password-gated by design, not TOTP-gated. A recovery code that stops being accepted fails _closed_ — it denies access rather than granting it, so it is a bug report, not a vulnerability.
|
||||
- **Group permissions** (`/auth/revoke-user-group` and siblings). The group-permission surface is unfinished and not exposed anywhere in the product. Findings there are not reachable by any user.
|
||||
- **Correctness bugs with no security boundary crossed.** For example, an API that reports success while silently leaving data behind. We want these reported — please open a GitHub issue — but they are not bounty-eligible.
|
||||
- **Missing Subresource Integrity (SRI) on external scripts/styles.** Accepted as hardening, not as a vulnerability, absent a demonstrated compromise of the CDN in question.
|
||||
- **General "token in a URL" / token-lifetime / token-storage designs** — signed directory URLs exposing children, a write signature implying read, app tokens outliving a web session, or the session token being held in `localStorage` on `puter.com`. These are current intended behaviors. Shorter-lived tokens with a refresh flow are on the roadmap; their absence is not a vulnerability on its own. Show a reproducible attack that does not already presuppose script execution on `puter.com`.
|
||||
- **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.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
·
|
||||
<a href="https://apps.puter.com/">App Store</a>
|
||||
·
|
||||
<a href="https://builder.puter.com/">AI Builder</a>
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
@@ -46,6 +48,8 @@ npm start
|
||||
```
|
||||
**→** This should launch Puter at http://puter.localhost:4100
|
||||
|
||||
To run this checkout with Docker, follow [Building from source](./doc/self-hosting.md#building-from-source-instead-of-pulling). Create a local `docker-compose.override.yml` to select the local build; keeping these settings out of `docker-compose.yml` avoids conflicts when pulling updates and keeps local configuration out of pull requests.
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team": 50,
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+30
-6
@@ -32,6 +32,10 @@
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -285,12 +289,26 @@
|
||||
// deleting yours frees the slot. Default 1.
|
||||
"max_teams_per_user": 1,
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account
|
||||
// taking a name from the global username pool, so this is what bounds a
|
||||
// team's blast radius until per-seat billing lands. Lowering it below
|
||||
// a team's current seat count blocks new provisioning and disables
|
||||
// nobody. Default 50.
|
||||
"max_seats_per_team": 50,
|
||||
// Seats one team may provision. Each seat is a real Puter account taking a
|
||||
// name from the global username pool, so this is what bounds a team's blast
|
||||
// radius. The owner's plan decides which of the two applies; defaults are 4
|
||||
// and 40. Lowering either below a team's current seat count blocks new
|
||||
// provisioning and disables nobody. Deployments upgrading from the old flat
|
||||
// default of 50: without a billing extension every owner reads as free, so
|
||||
// set `max_seats_per_team` to keep the old behavior.
|
||||
"max_seats_per_team_free": 4,
|
||||
"max_seats_per_team_paid": 40,
|
||||
//
|
||||
// One flat cap for every team, whatever the owner pays. Setting it
|
||||
// overrides both of the above, so a deployment that does not sell seats can
|
||||
// ignore plans entirely. Unset by default — leave it that way to keep the
|
||||
// free cap meaningful.
|
||||
// "max_seats_per_team": 50,
|
||||
//
|
||||
// Staged rollout: only these email domains may create a team, and the tab
|
||||
// renders for them even with `gui_params.teams_ui` off. Members of an
|
||||
// existing team always pass. Unset means everyone, gated by `teams_ui`.
|
||||
// "teams_allowed_email_domains": ["puter.com"],
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
// How long a notification is kept, in days from creation. Acknowledged or
|
||||
@@ -524,4 +542,10 @@
|
||||
// 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
|
||||
|
||||
// How many full months past its own a monthly usage record is kept before
|
||||
// it expires. Defaults to 3; set to 0 to keep every month's records
|
||||
// forever. Changing it, including to 0, applies to records first written
|
||||
// after the change; records already stamped keep their expiry.
|
||||
// "meteringRetentionMonths": 3
|
||||
}
|
||||
@@ -59,6 +59,16 @@ We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStor
|
||||
|
||||
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
|
||||
|
||||
## Actors
|
||||
|
||||
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has two app-shaped fields and they are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
|
||||
|
||||
`makeActor` resolves `effectiveApp` once at construction, and `assertResolvedActor` at the request edge rejects any actor that skipped it — so reading `effectiveApp` needs no fallback. Reach for `app` only where the direct app genuinely is the question, and say why in a comment.
|
||||
|
||||
- `actor.effectiveApp` — which app is acting. This is the default; nearly every scoping rule, namespace key and attribution wants it.
|
||||
- `isAppActor(actor)` — a direct app actor specifically. Use it where a token must *not* be treated as its issuer, e.g. the permission scanners that read an app's own grant rows.
|
||||
- `isPlainUserActor(actor)` — the account acting through nothing at all; `isAccountContext(actor)` for the wider "the account's own reach", which also admits a full-access token.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
|
||||
|
||||
@@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
|
||||
- **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
|
||||
|
||||
+42
-1
@@ -408,6 +408,12 @@ 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.
|
||||
|
||||
For API testing, you can also set `"unlimitedMetering": true` in your
|
||||
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
|
||||
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
|
||||
This includes guest accounts and applies to the whole local instance. Keep normal
|
||||
metering settings when testing budget or subscription enforcement.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
@@ -418,6 +424,17 @@ 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.
|
||||
|
||||
Monthly usage records expire on their own once they're old enough to be of no
|
||||
further use — `meteringRetentionMonths` full months past the month they
|
||||
belong to, 3 by default. Set it to `0` to keep every month's records forever:
|
||||
|
||||
```json
|
||||
"meteringRetentionMonths": 6
|
||||
```
|
||||
|
||||
Changing it, including to `0`, applies to records first written after the
|
||||
change; records already stamped keep their expiry.
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
@@ -488,12 +505,36 @@ For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama`
|
||||
|
||||
## Building from source instead of pulling
|
||||
|
||||
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
|
||||
To run a local build against the full stack, use a source checkout and complete
|
||||
the configuration steps above. Create `docker-compose.override.yml` in the
|
||||
repository root, next to [docker-compose.yml](../docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
If that file already exists, merge these settings into its `puter` service.
|
||||
Compose loads and merges the override automatically. `build.context: .` selects
|
||||
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
|
||||
|
||||
Use this override for local build settings instead of editing `docker-compose.yml`.
|
||||
The override is ignored by Git, keeping local configuration out of pull requests
|
||||
and avoiding conflicts in the shared Compose file when pulling updates.
|
||||
|
||||
From the repository root, build and start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Run the same command after changing or pulling source code to rebuild the image.
|
||||
To return to the published image, remove the local build settings (or the override
|
||||
file if those are its only settings), then run `docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
## Managing running backend
|
||||
|
||||
+2
-9
@@ -230,15 +230,8 @@ services:
|
||||
puter:
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
# Uncomment to build from this directory instead of pulling the published
|
||||
# image. Also flip pull_policy to `never` so compose doesn't overwrite
|
||||
# your local build by re-pulling :latest.
|
||||
# build:
|
||||
# context: .
|
||||
# # buildx-only: cross-compile to both archs in a single push
|
||||
# # platforms:
|
||||
# # - linux/amd64
|
||||
# # - linux/arm64
|
||||
# For local builds, create a docker-compose.override.yml;
|
||||
# see doc/self-hosting.md#building-from-source-instead-of-pulling.
|
||||
container_name: puter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor } from '../src/backend/core/actor.ts';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
@@ -48,6 +49,7 @@ beforeAll(async () => {
|
||||
create_shortcut: true,
|
||||
payment_bypass: true,
|
||||
},
|
||||
teams_enabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
@@ -66,6 +68,81 @@ const seedUser = async () => {
|
||||
};
|
||||
|
||||
describe('whoami extension — handleWhoami', () => {
|
||||
// The sidebar label needs this at boot, which is why it rides whoami
|
||||
// rather than a call of its own.
|
||||
describe('the team an account belongs to', () => {
|
||||
// A seat is created, never adopted, so it must have no password.
|
||||
const seedSeat = async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.user.create({
|
||||
username: `wseat_${slug}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
});
|
||||
};
|
||||
|
||||
const seatOf = async (teamName: string) => {
|
||||
const owner = await seedUser();
|
||||
const seat = await seedSeat();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id as number,
|
||||
name: teamName,
|
||||
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
|
||||
});
|
||||
await server.stores.team.addMember(team.uid, seat.id as number, {
|
||||
orgOwned: true,
|
||||
});
|
||||
return { seat, team };
|
||||
};
|
||||
|
||||
it('names the team for a seat', async () => {
|
||||
const { seat, team } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: seat.uuid, id: seat.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect((captured.body as { team?: unknown }).team).toEqual({
|
||||
uid: team.uid,
|
||||
name: 'Acme Corp',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing for an account that is not a seat', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
|
||||
it('withholds it from an app actor', async () => {
|
||||
// Same class as the phone number: a seat's employer is not an
|
||||
// app's business.
|
||||
const { seat } = await seatOf('Acme Corp');
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: makeActor({
|
||||
user: { uuid: seat.uuid, id: seat.id as number },
|
||||
app: { uid: 'app-1' },
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('team');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when no actor is on the context', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -186,10 +263,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -238,10 +315,10 @@ describe('whoami extension — handleWhoami', () => {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
@@ -269,11 +346,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
|
||||
makeActor({
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
}),
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
|
||||
+29
-19
@@ -1,6 +1,9 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import {
|
||||
cardFallbackDepsFrom,
|
||||
isCardFallbackEligible,
|
||||
} from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -107,7 +110,7 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const isUser = !actor.app;
|
||||
const isUser = !actor.effectiveApp;
|
||||
const user = await stores.user.getById(actor.user.id);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
@@ -156,6 +159,8 @@ export const handleWhoami = async (
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// A seat reaches nothing until it replaces its admin's password.
|
||||
requires_password_change: user.requires_password_change,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
@@ -168,19 +173,7 @@ export const handleWhoami = async (
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
{
|
||||
smsConfigured: () =>
|
||||
Boolean(clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const status = { enabled: null as boolean | null };
|
||||
await clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
status,
|
||||
{},
|
||||
);
|
||||
return status.enabled;
|
||||
},
|
||||
},
|
||||
cardFallbackDepsFrom(clients),
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -251,6 +244,23 @@ export const handleWhoami = async (
|
||||
details.directories = directories;
|
||||
}
|
||||
|
||||
// The team an account belongs to, when it is one a team pays for. User
|
||||
// actors only, and only where teams are on.
|
||||
if (isUser && extension.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
details.team = {
|
||||
uid: seat.team_uid,
|
||||
name: seat.team_name ?? null,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Never fail whoami over this; the account still works without it.
|
||||
console.warn('[whoami] team lookup failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Last activity
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
@@ -277,8 +287,9 @@ export const handleWhoami = async (
|
||||
delete details.referral_code;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
details.app_name = actor.app.uid;
|
||||
const app = actor.effectiveApp;
|
||||
if (app) {
|
||||
details.app_name = app.uid;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -292,8 +303,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
Generated
+93
-638
@@ -16,7 +16,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"libphonenumber-js": "1.13.13",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
@@ -3268,9 +3268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.15.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
|
||||
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6055,9 +6055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.13.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
|
||||
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -7960,14 +7960,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
@@ -7975,6 +7976,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
@@ -8596,6 +8601,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -11112,10 +11127,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11478,9 +11492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.12",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz",
|
||||
"integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==",
|
||||
"version": "1.13.13",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.13.tgz",
|
||||
"integrity": "sha512-hV6SmWnyQ8U1QXhhqbqYBHIStNnscG7A5XbGodgdHhf/8N5PwhYpJA5U/yHDjYwPYTd8uNGXqq416XctuvK2PA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
@@ -13318,19 +13332,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.22.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz",
|
||||
"integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==",
|
||||
"version": "3.24.4",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"sql-escaper": "^1.4.0"
|
||||
"sql-escaper": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
@@ -13487,9 +13500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz",
|
||||
"integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==",
|
||||
"version": "9.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
|
||||
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -14211,6 +14224,12 @@
|
||||
"node": ">= 10.12"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
|
||||
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -14317,9 +14336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -17174,9 +17193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler": {
|
||||
"version": "4.124.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz",
|
||||
"integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==",
|
||||
"version": "4.132.0",
|
||||
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.132.0.tgz",
|
||||
"integrity": "sha512-61HD7Unw3g7h9zSQoqldzfL3MEbCKUVfwtlMgfSQtjYwMjBD8z83K/EXnYGv75R5hczx3grFfjYH+tdmJm5PHg==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -17184,10 +17203,10 @@
|
||||
"@cloudflare/unenv-preset": "2.16.1",
|
||||
"blake3-wasm": "2.1.5",
|
||||
"esbuild": "0.28.1",
|
||||
"miniflare": "5.20260815.0-alpha",
|
||||
"miniflare": "5.20260915.0-alpha",
|
||||
"path-to-regexp": "6.3.0",
|
||||
"unenv": "2.0.0-rc.24",
|
||||
"workerd": "1.20260815.1"
|
||||
"workerd": "1.20260915.1"
|
||||
},
|
||||
"bin": {
|
||||
"cf-wrangler": "bin/cf-wrangler.js",
|
||||
@@ -17201,7 +17220,7 @@
|
||||
"fsevents": "2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260815.1"
|
||||
"@cloudflare/workers-types": "^5.20260915.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cloudflare/workers-types": {
|
||||
@@ -17210,9 +17229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-EFjNxN4l5caJoHADhchROvO52EFFt+zmJvFZRmwX6UU9pE53BAbF94wRBhbpylv5Ethsd1TdJNwYuUPgZYbETg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17227,9 +17246,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-Xm2jbLw8M/o5YkuI5VsHCVU4rgnzr3ROl+Awk7FCBJ3mtLJNuxPtJNB/zZI+/t+3QtgsSPnmTisS5Gq6/KLQ/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -17244,9 +17263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-hkQp+PWBp7U6uDMd6hqUf4lfErPV79jolLaK6j4U50HrHC4jqWxESPemkxEk8H7p9jTKkZ9I9Z0nA5DLAE0J6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17261,9 +17280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-3yi8/irVGNQbuYTSmLXcEGupk4Js42jU3kyAx88MDbwGQUa34VbucKcNwHwfhqwEUNaIrn8ohlOEcNJChQl0Og==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -17278,9 +17297,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz",
|
||||
"integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260915.1.tgz",
|
||||
"integrity": "sha512-+FQ3o/grK2tQy6dw3AqSOYWsYROZqxCQKBqSHQVcXvr2x2QetCT64OrvAAJ0hpQAokREFwQyzsNmMiZbAdW0zw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -17294,525 +17313,17 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
|
||||
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
|
||||
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
|
||||
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
|
||||
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
|
||||
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
|
||||
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
|
||||
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
|
||||
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
|
||||
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
|
||||
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
|
||||
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
|
||||
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
|
||||
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/miniflare": {
|
||||
"version": "5.20260815.0-alpha",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz",
|
||||
"integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==",
|
||||
"version": "5.20260915.0-alpha",
|
||||
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260915.0-alpha.tgz",
|
||||
"integrity": "sha512-ApLsq9X2jbpkLAPXx89afGvuNxAFzd1rUK+nzfBSLu+34YWc2Q2YlgH6f3kSEEQYZ2gAlC5Q5IP6tDNNg/1KqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "0.8.1",
|
||||
"sharp": "0.35.2",
|
||||
"sharp": "0.35.4",
|
||||
"undici": "7.29.0",
|
||||
"workerd": "1.20260815.1",
|
||||
"workerd": "1.20260915.1",
|
||||
"ws": "8.21.0",
|
||||
"youch": "4.1.0-beta.10"
|
||||
},
|
||||
@@ -17827,55 +17338,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrangler/node_modules/sharp": {
|
||||
"version": "0.35.2",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
|
||||
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
|
||||
"node_modules/wrangler/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.8.4"
|
||||
},
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.2",
|
||||
"@img/sharp-darwin-x64": "0.35.2",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.2",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.1",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.1",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.1",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
|
||||
"@img/sharp-linux-arm": "0.35.2",
|
||||
"@img/sharp-linux-arm64": "0.35.2",
|
||||
"@img/sharp-linux-ppc64": "0.35.2",
|
||||
"@img/sharp-linux-riscv64": "0.35.2",
|
||||
"@img/sharp-linux-s390x": "0.35.2",
|
||||
"@img/sharp-linux-x64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.2",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.2",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.2",
|
||||
"@img/sharp-win32-arm64": "0.35.2",
|
||||
"@img/sharp-win32-ia32": "0.35.2",
|
||||
"@img/sharp-win32-x64": "0.35.2"
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/workerd": {
|
||||
"version": "1.20260815.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz",
|
||||
"integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==",
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
|
||||
"integrity": "sha512-9Nqh22lW/ondXnMkFTbn65MHZLdsE2izJ9sFIVdqAufQTEVWnUkcpp7T5JtLr4ASN/VuxlTVZ5xgYntyo+d4ag==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -17886,11 +17362,11 @@
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@cloudflare/workerd-darwin-64": "1.20260815.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260815.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260815.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260815.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260815.1"
|
||||
"@cloudflare/workerd-darwin-64": "1.20260915.1",
|
||||
"@cloudflare/workerd-darwin-arm64": "1.20260915.1",
|
||||
"@cloudflare/workerd-linux-64": "1.20260915.1",
|
||||
"@cloudflare/workerd-linux-arm64": "1.20260915.1",
|
||||
"@cloudflare/workerd-windows-64": "1.20260915.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
@@ -18152,7 +17628,7 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.1",
|
||||
"compression": "^1.8.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dedent": "^1.5.3",
|
||||
"dynalite": "^4.0.0",
|
||||
@@ -18170,7 +17646,7 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"murmurhash": "^2.0.1",
|
||||
"mysql2": "^3.22.6",
|
||||
"nodemailer": "^9.0.6",
|
||||
"nodemailer": "^9.1.1",
|
||||
"openai": "^6.34.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"pg": "^8.21.0",
|
||||
@@ -18191,7 +17667,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
@@ -18806,28 +18282,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/js-yaml": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"license": "MIT",
|
||||
@@ -18983,12 +18437,13 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
"open": "^10.2.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"postal-mime": "^3.0.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"libphonenumber-js": "1.13.13",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -17,24 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Optional ClickHouse client.
|
||||
*
|
||||
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
|
||||
* stats read path (open / unique-user counts) falls back to the primary SQL
|
||||
* database. `this.clients.clickhouse` is therefore typed as optional and is
|
||||
* `undefined` in default and self-hosted setups; callers MUST branch on its
|
||||
* presence and fall back to SQL.
|
||||
*
|
||||
* A production deployment can register a real ClickHouse client via an
|
||||
* extension (`extension.registerClient('clickhouse', client)`) to offload the
|
||||
* analytics queries off the primary database and keep the stats path fast at
|
||||
* scale. When registered, the instance flows into `this.clients.clickhouse`
|
||||
* everywhere it's typed.
|
||||
*
|
||||
* Only the surface the stats path actually consumes is declared here. Extend
|
||||
* this interface (don't widen to `any`) when a new query shape is needed.
|
||||
*/
|
||||
// Optional analytics client, registered by an extension. Only the surface the
|
||||
// app-stats path consumes is declared; extend it rather than widening to `any`.
|
||||
|
||||
export interface ClickhouseQueryResult {
|
||||
json<T = Record<string, unknown>>(): Promise<T[]>;
|
||||
}
|
||||
|
||||
@@ -693,31 +693,19 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
expect(createdPools[1].ended).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the pools once the drain window elapses', async () => {
|
||||
it('leaves the pools open through prepare-shutdown; only onServerShutdown closes them', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
// No timer closes the pools on its own — the layers above are
|
||||
// still draining through them for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the drain timer when shutdown arrives first', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
// A second prepare is a no-op — only one drain timer may exist.
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { createPool, ExecuteValues, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
@@ -54,9 +54,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: Pool;
|
||||
private db!: SQLBatcher;
|
||||
private dbReplica!: SQLBatcher;
|
||||
/** Primary pool, SELECT-only: same rows as `db`, without the transaction. */
|
||||
private dbPrimaryRead!: SQLBatcher;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -79,6 +80,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -98,29 +100,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
// Allow in-flight queries to drain before closing pools
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools('drain').catch((e) =>
|
||||
console.error('[mysql] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
|
||||
// onServerShutdown closes them after the layers above have drained.
|
||||
console.log('[mysql] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools('shutdown');
|
||||
}
|
||||
|
||||
@@ -162,7 +147,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.db.execute(query, params);
|
||||
const result = await this.dbPrimaryRead.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
@@ -201,7 +186,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
await conn.beginTransaction();
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await conn.execute(statement, values);
|
||||
await conn.execute(statement, values as ExecuteValues);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
@@ -224,7 +209,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
// Run both reads in parallel — prefer replica when it returns rows,
|
||||
// otherwise fall back to primary to handle replication lag.
|
||||
const primaryPromise = this.db.execute(query, params);
|
||||
const primaryPromise = this.dbPrimaryRead.execute(query, params);
|
||||
try {
|
||||
const replicaResult = await this.dbReplica.execute(query, params);
|
||||
if (
|
||||
@@ -354,6 +339,22 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads that must see the primary still only read, so they skip the
|
||||
* batcher's transaction wrapper. Worth a separate batcher because BEGIN and
|
||||
* COMMIT are round trips: against a primary in another region they cost
|
||||
* more than the query.
|
||||
*/
|
||||
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
@@ -378,6 +379,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { FieldDef, QueryResult } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import {
|
||||
@@ -357,4 +357,33 @@ describe('PostgresDatabaseClient', () => {
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the pool open through prepare-shutdown; only onServerShutdown closes it', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pool = new RecordingPool();
|
||||
let ended = false;
|
||||
pool.end = async () => {
|
||||
ended = true;
|
||||
};
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => pool,
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(ended).toBe(false);
|
||||
|
||||
// No timer closes the pool on its own — the layers above are
|
||||
// still draining through it for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,6 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
@@ -172,28 +171,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools().catch((e) =>
|
||||
console.error('[postgres] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
// Pools stay open; onServerShutdown closes them after the layers above
|
||||
// have drained through them.
|
||||
console.log('[postgres] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools();
|
||||
}
|
||||
|
||||
|
||||
@@ -284,11 +284,16 @@ export class SQLBatcher {
|
||||
// individually below. Without this, MySQL would commit every
|
||||
// statement up to the failure point and a per-item retry would
|
||||
// misreport already-committed inserts as duplicate-key failures.
|
||||
//
|
||||
// A read-only batch has nothing to roll back, and the wrapper is not
|
||||
// free: BEGIN and COMMIT are round trips of their own, which triples
|
||||
// the cost of a read against a pool in another region.
|
||||
const wrapInTransaction = !this.readOnly;
|
||||
let batchSucceeded = false;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
if (wrapInTransaction) await connection.beginTransaction();
|
||||
const [results, fields] = await connection.query(query, values);
|
||||
await connection.commit();
|
||||
if (wrapInTransaction) await connection.commit();
|
||||
batchSucceeded = true;
|
||||
this.#consecutiveFailures = 0;
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
@@ -296,10 +301,12 @@ export class SQLBatcher {
|
||||
b.resolve([results[i], fields?.[i]]);
|
||||
}
|
||||
} catch (batchError) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
if (wrapInTransaction) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn('SQLBatcher rollback failed:', rollbackError);
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'SQLBatcher batch failed; retrying items individually:',
|
||||
@@ -311,10 +318,11 @@ export class SQLBatcher {
|
||||
|
||||
if (batchSucceeded) return;
|
||||
|
||||
// Per-item fallback. The transaction was rolled back so no statement
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
// Per-item fallback. Nothing from the batch is live — it was rolled
|
||||
// back, or carried only SELECTs — so re-running each item
|
||||
// independently produces clean success/failure outcomes for each
|
||||
// caller. Concurrency is capped to avoid briefly saturating the pool
|
||||
// when a large batch fails.
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
|
||||
@@ -86,6 +86,45 @@ describe('SQLBatcher', () => {
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips the transaction wrapper on a readOnly batcher', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
// BEGIN and COMMIT are round trips; a SELECT-only batch has nothing to
|
||||
// roll back, so it must not pay for them.
|
||||
expect(conn.beginTransaction).not.toHaveBeenCalled();
|
||||
expect(conn.commit).not.toHaveBeenCalled();
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not roll back a failed readOnly batch', async () => {
|
||||
const conn = makeConnection((sql: string) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
|
||||
return [[{ n: 0 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(conn.rollback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
|
||||
@@ -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 = 79;
|
||||
const CURRENT_SCHEMA_VERSION = 82;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
await client.write(
|
||||
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
'app-cascade-test',
|
||||
user.id,
|
||||
'cascade-test',
|
||||
'cascade-test',
|
||||
'https://cascade.test/',
|
||||
],
|
||||
);
|
||||
const [app] = (await client.read(
|
||||
'SELECT `id` FROM `apps` WHERE `uid` = ?',
|
||||
['app-cascade-test'],
|
||||
)) as { id: number }[];
|
||||
const insertSubdomain = (
|
||||
uuid: string,
|
||||
name: string,
|
||||
appOwner: number | null,
|
||||
) =>
|
||||
client.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[uuid, name, user.id, appOwner],
|
||||
);
|
||||
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
|
||||
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
|
||||
|
||||
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
|
||||
['cascade-%'],
|
||||
),
|
||||
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
|
||||
});
|
||||
|
||||
it('keeps every subdomains column through the 0086 rebuild', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((c) => c.name)).toEqual([
|
||||
'id',
|
||||
'uuid',
|
||||
'subdomain',
|
||||
'user_id',
|
||||
'root_dir_id',
|
||||
'associated_app_id',
|
||||
'ts',
|
||||
'app_owner',
|
||||
'protected',
|
||||
'domain',
|
||||
'database_id',
|
||||
'preamble_version',
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
|
||||
@@ -113,6 +113,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[76, ['0081_event-subscriptions-indexes.sql']],
|
||||
[77, ['0082_temp-password-expiry.sql']],
|
||||
[78, ['0083_team-directory.sql']],
|
||||
[79, ['0084_share-anyone-with-link.sql']],
|
||||
[80, ['0085_event-subscriptions-include-value.sql']],
|
||||
[81, ['0086_subdomains-app-owner-cascade.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col
|
||||
-- and the index through a guarded procedure, as mysql_mig_34 does.
|
||||
|
||||
CALL _puter_add_col('share', 'anyone', '`anyone` tinyint(1) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_share_anyone_index()
|
||||
BEGIN
|
||||
-- Every other row leaves `anyone` NULL, so this binds only link shares.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_anyone'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_anyone` (`fsentry_id`, `anyone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_share_anyone_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. No per-file applied-state tracking, so it goes through
|
||||
-- _puter_add_col as mysql_mig_39 does.
|
||||
|
||||
CALL _puter_add_col('event_subscriptions', 'include_value', '`include_value` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column
|
||||
-- rather than by name, since an older database need not carry the name
|
||||
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
|
||||
-- DELETE_RULE already CASCADE and does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
|
||||
BEGIN
|
||||
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
|
||||
|
||||
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
|
||||
SET fk_name = (
|
||||
SELECT rc.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE <> 'CASCADE'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
|
||||
PREPARE stmt FROM @s;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE = 'CASCADE'
|
||||
) THEN
|
||||
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_subdomains_app_owner_cascade();
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS anyone smallint;
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only link shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_anyone ON share (fsentry_id, anyone);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
|
||||
-- rationale. Idempotent via IF NOT EXISTS; there is no per-file applied-state
|
||||
-- tracking.
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS include_value smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column, not
|
||||
-- by name. Idempotent: there is no per-file applied-state tracking, and a
|
||||
-- replay sees the cascade already in place and does nothing.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
fk_name text;
|
||||
BEGIN
|
||||
SELECT c.conname INTO fk_name
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype <> 'c'
|
||||
LIMIT 1;
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype = 'c'
|
||||
) THEN
|
||||
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
|
||||
FOREIGN KEY (app_owner) REFERENCES apps (id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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/>.
|
||||
|
||||
-- "Anyone with the link": a share row with no holder of any kind — not a
|
||||
-- person, not a team, not an invite. `anyone` is 1 on such a row and NULL on
|
||||
-- every other, so the unique index binds only these: one per node, whoever
|
||||
-- last set it. No permission row stands behind it; ACLService reads the share
|
||||
-- row itself, and honours it only while the owner's plan covers link sharing.
|
||||
|
||||
ALTER TABLE `share` ADD COLUMN `anyone` INTEGER DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_anyone`
|
||||
ON `share` (`fsentry_id`, `anyone`);
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
-- 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/>.
|
||||
|
||||
-- A key-value subscription may ask for the key's new value to ride along with
|
||||
-- each delivery. Off by default: a delivery that carries nothing but the key is
|
||||
-- what every existing row was made under.
|
||||
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `include_value` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- 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/>.
|
||||
|
||||
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
|
||||
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
|
||||
-- unowned worker row is redeployed with the account's own credential.
|
||||
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE `subdomains_new` (
|
||||
`id` INTEGER PRIMARY KEY,
|
||||
`uuid` varchar(40) DEFAULT NULL,
|
||||
`subdomain` varchar(64) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`root_dir_id` int(10) DEFAULT NULL,
|
||||
`associated_app_id` int(10) DEFAULT NULL,
|
||||
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`app_owner` int(10) DEFAULT NULL,
|
||||
`protected` tinyint(1) DEFAULT '0',
|
||||
`domain` varchar(256) DEFAULT NULL,
|
||||
`database_id` varchar(40) DEFAULT NULL,
|
||||
`preamble_version` varchar(64) DEFAULT NULL,
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `subdomains_new`
|
||||
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
|
||||
SELECT
|
||||
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
|
||||
FROM `subdomains`;
|
||||
|
||||
DROP TABLE `subdomains`;
|
||||
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -559,3 +559,118 @@ describe('DDBClient — unprocessed batch items', () => {
|
||||
).resolves.toEqual({ ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// UnprocessedKeys are batchGet's analogue of UnprocessedItems above, driven
|
||||
// against the same kind of wire-format stub.
|
||||
describe('DDBClient — unprocessed batch keys', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let responses: unknown[];
|
||||
let requestCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const next = responses.shift() ?? {};
|
||||
requestCount += 1;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const stubClient = () =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
const unprocessed = (pk: string) => ({
|
||||
UnprocessedKeys: {
|
||||
[TABLE]: { Keys: [{ pk: { S: pk } }] },
|
||||
},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
|
||||
});
|
||||
|
||||
it('retries leftover keys and merges responses and capacity across attempts', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
unprocessed('retry-me'),
|
||||
{
|
||||
Responses: {
|
||||
[TABLE]: [{ pk: { S: 'retry-me' }, v: { N: '1' } }],
|
||||
},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await stubClient().batchGet([
|
||||
{ table: TABLE, items: { pk: 'retry-me' } },
|
||||
]);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.Responses?.[TABLE]).toEqual([{ pk: 'retry-me', v: 1 }]);
|
||||
expect(result.ConsumedCapacity).toEqual([
|
||||
{ TableName: TABLE, CapacityUnits: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up with a bad-request error when keys never drain', async () => {
|
||||
requestCount = 0;
|
||||
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'stuck' } }]),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Failed to batch get all items from DynamoDB',
|
||||
});
|
||||
// One initial attempt plus the full retry budget.
|
||||
expect(requestCount).toBe(9);
|
||||
}, 30_000);
|
||||
|
||||
it('tolerates a response that reports no items or capacity at all', async () => {
|
||||
requestCount = 0;
|
||||
responses = [{ Responses: {}, UnprocessedKeys: {} }];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'quiet' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores capacity entries with no table name', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
{
|
||||
Responses: {},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ CapacityUnits: 7 }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'anon' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
|
||||
const MAX_BATCH_WRITE_ITEMS = 25;
|
||||
const MAX_BATCH_WRITE_RETRIES = 8;
|
||||
const BATCH_WRITE_RETRY_BASE_MS = 25;
|
||||
const MAX_BATCH_GET_RETRIES = 8;
|
||||
const BATCH_GET_RETRY_BASE_MS = 25;
|
||||
|
||||
// In-memory mode gives each DDBClient its own unique key, which keys
|
||||
// into a fresh dynalite server. This is what test parallelism needs:
|
||||
@@ -234,6 +236,9 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
// UnprocessedKeys retried with capped exponential backoff, same posture as
|
||||
// #batchWrite's UnprocessedItems: a batch this size can be throttled or
|
||||
// partially served, and the caller shouldn't have to know that.
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
@@ -250,25 +255,84 @@ export class DDBClient extends PuterClient {
|
||||
{} as Record<string, Record<string, unknown>[]>,
|
||||
);
|
||||
|
||||
const requestItems: BatchGetCommandInput['RequestItems'] =
|
||||
Object.entries(allRequestItemsPerTable).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const command = new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
|
||||
allRequestItemsPerTable,
|
||||
).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
return client.send(command);
|
||||
const responsesByTable = new Map<string, Record<string, unknown>[]>();
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
|
||||
if (Object.keys(requestItems).length === 0) break;
|
||||
|
||||
const response = await client.send(
|
||||
new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [table, items] of Object.entries(
|
||||
response.Responses ?? {},
|
||||
)) {
|
||||
const existing = responsesByTable.get(table) ?? [];
|
||||
existing.push(...items);
|
||||
responsesByTable.set(table, existing);
|
||||
}
|
||||
for (const entry of response.ConsumedCapacity ?? []) {
|
||||
if (!entry.TableName) continue;
|
||||
consumedCapacityByTable.set(
|
||||
entry.TableName,
|
||||
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
|
||||
Number(entry.CapacityUnits ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const unprocessedKeys = response.UnprocessedKeys ?? {};
|
||||
if (Object.keys(unprocessedKeys).length === 0) {
|
||||
requestItems = {};
|
||||
break;
|
||||
}
|
||||
|
||||
requestItems = unprocessedKeys as NonNullable<
|
||||
BatchGetCommandInput['RequestItems']
|
||||
>;
|
||||
if (attempt < MAX_BATCH_GET_RETRIES) {
|
||||
const delayMs = Math.min(
|
||||
1000,
|
||||
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(requestItems).length > 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch get all items from DynamoDB',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
Responses: Object.fromEntries(responsesByTable),
|
||||
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
|
||||
([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface EmailAttachment {
|
||||
path?: string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
/** Content-ID of an inline part the html body references as `cid:`. */
|
||||
cid?: string;
|
||||
contentDisposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
|
||||
@@ -184,7 +184,7 @@ const SHARE_LIST_ROW = `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
@@ -351,9 +351,11 @@ [email protected] immediately.
|
||||
subject: 'Your {{team_name}} account on Puter',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
|
||||
They will send you a temporary password separately; you will be asked to
|
||||
choose your own the first time you sign in.</p>
|
||||
<p>{{team_name}} has created a Puter account for you.</p>
|
||||
<p>Username: <b>{{username}}</b><br>
|
||||
Temporary password: <b>{{temporary_password}}</b></p>
|
||||
<p>You will be asked to choose your own password the first time you sign in.
|
||||
This temporary one stops working then, and it expires on its own if unused.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
@@ -456,7 +458,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
|
||||
{{/if}}{{/each}}{{/each}}
|
||||
|
||||
@@ -494,7 +496,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{/each}}
|
||||
|
||||
There's no Puter account for {{email}} yet. Create one with this
|
||||
|
||||
@@ -54,19 +54,9 @@ export type TeamBillingEvent = TeamBillingContext & {
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
|
||||
* events declare the payload here by declaration merging, so both the emitter
|
||||
* and every listener are typed against the same shape:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/event/types' {
|
||||
* interface IExtensionEventMap {
|
||||
* 'my.thing.happened': { thingId: string };
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Deliberately member-less and index-signature-free: an index signature here
|
||||
* would widen `keyof EventMap` to `string` and silently disable key checking on
|
||||
* every `emit` in the tree.
|
||||
* Extension-augmentable half of {@link EventMap}, declaration-merged like
|
||||
* `IExtensionClientInstances`. No index signature: it would widen `keyof
|
||||
* EventMap` to `string` and disable key checking on every `emit`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IExtensionEventMap {}
|
||||
@@ -237,8 +227,40 @@ export type EventMap = {
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
|
||||
bonus_code?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Signup bonus codes are pure mechanism here: an extension decides what a
|
||||
// code is worth and fills these in (both emitted via `emitAndWait`).
|
||||
'puter.signup-bonus.check': {
|
||||
code: string;
|
||||
ip: string | null;
|
||||
fingerprint: string | null;
|
||||
valid: boolean;
|
||||
/** Opaque to core; forwarded to the client when `valid` is false. */
|
||||
reason: string | null;
|
||||
display: { title: string; description: string } | null;
|
||||
/** Verification the code will require after signup. */
|
||||
requirements: { phone: boolean; card: boolean } | null;
|
||||
};
|
||||
/**
|
||||
* Emitted once a signup has passed `puter.signup.validate`, so listeners
|
||||
* see the harness's verdict. A listener sets `accepted` to honor the code,
|
||||
* and may raise (never lower) the verification requirements.
|
||||
*/
|
||||
'puter.signup-bonus.validate': {
|
||||
code: string;
|
||||
email?: string;
|
||||
clean_email?: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
reputation?: number | null;
|
||||
source?: 'oidc';
|
||||
requires_phone_verification: boolean;
|
||||
requires_card_verification: boolean;
|
||||
accepted: boolean;
|
||||
};
|
||||
'email.validate': {
|
||||
email: string;
|
||||
allow: boolean;
|
||||
@@ -294,6 +316,7 @@ export type EventMap = {
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
@@ -320,6 +343,7 @@ export type EventMap = {
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
email?: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
@@ -431,6 +455,14 @@ export type EventMap = {
|
||||
userId: number;
|
||||
keys: string[];
|
||||
op: KvOp;
|
||||
/**
|
||||
* What each key holds after the change, aligned with `keys`: the
|
||||
* written value on a `set`, `null` on a `del`. Absent when the write
|
||||
* did not have it in hand, as an `expire` does not.
|
||||
*/
|
||||
values?: unknown[];
|
||||
/** Keys among `keys` private to the namespace's app. */
|
||||
noShareKeys?: string[];
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
@@ -607,6 +639,16 @@ export type EventMap = {
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
// Asked before a hosted file is streamed. A listener that owns the site
|
||||
// sets `result.allowed = false` to withhold the entry; the visitor then
|
||||
// sees the same 404 as for a missing file.
|
||||
'site.access.check': {
|
||||
subdomain: string;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
entry: { name: string; path: string };
|
||||
result: { allowed: boolean };
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
@@ -695,6 +737,11 @@ export type EventMap = {
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
// Cost factor for recorded AI usage, keyed by driver and model:
|
||||
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
|
||||
// per batch; the last listener to set `factor` wins.
|
||||
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
@@ -715,6 +762,18 @@ export type EventMap = {
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
} & IExtensionEventMap;
|
||||
|
||||
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
|
||||
export type AiCostFactorEvent = {
|
||||
/** Driver doing the pricing, e.g. `ai-chat`. */
|
||||
driver: string;
|
||||
/** `<provider>:<model>` the usage is recorded under. */
|
||||
model: string;
|
||||
/** Who the usage is being charged to. */
|
||||
actor: Actor;
|
||||
/** Applied to the cost, starting at 1. Values <= 0 are ignored. */
|
||||
factor: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
* listener vetoes the call (sets `allow = false`); the call never runs and no
|
||||
|
||||
@@ -140,13 +140,9 @@ export interface EventsInvokeCall {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an invocation is delivered.
|
||||
*
|
||||
* `status` is what the handler answered with. A `null` status means nothing
|
||||
* ran: no transport, no script, or a dispatcher that refused us — all of which
|
||||
* are the platform's fault and so retriable. `handled` is set only when the
|
||||
* answer is provably the script's own (the handled header); a 4xx without it
|
||||
* came from something between here and the script, not a handler's refusal.
|
||||
* Delivers an invocation. `status` null means nothing ran (retriable);
|
||||
* `handled` is set only when the answer provably came from the script, so an
|
||||
* unmarked 4xx is not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
@@ -159,14 +155,9 @@ export interface EventsInvokeTransport {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call did.
|
||||
*
|
||||
* - `settled` — the handler took the delivery (2xx).
|
||||
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
|
||||
* the same code, so it is not retried.
|
||||
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
|
||||
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
|
||||
* reach the worker at all.
|
||||
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
|
||||
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
|
||||
* failure, unmarked 4xx).
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
|
||||
|
||||
@@ -107,14 +107,8 @@ const buildCluster = (config: IConfig): Cluster => {
|
||||
};
|
||||
|
||||
/**
|
||||
* `RedisClient` IS the ioredis `Cluster` instance — consumers call
|
||||
* `this.clients.redis.get(...)` / `.set(...)` directly rather than going
|
||||
* through an inner `.client` field. Lifecycle methods (`onServerShutdown`) are
|
||||
* attached onto the cluster instance itself.
|
||||
*
|
||||
* Type-wise, `RedisClient` is `Cluster & WithLifecycle`; the registry- facing
|
||||
* value below is a constructor that returns that shape. Mirrors the
|
||||
* `DatabaseClientFactory` pattern.
|
||||
* The ioredis `Cluster` itself, with lifecycle methods attached; the
|
||||
* constructor below returns it.
|
||||
*/
|
||||
export type RedisClient = Cluster & WithLifecycle;
|
||||
|
||||
|
||||
@@ -21,32 +21,19 @@ import type { IConfig, WithLifecycle } from '../types';
|
||||
import type { ClickhouseClient } from './clickhouse/ClickhouseClient';
|
||||
|
||||
/**
|
||||
* Extension-augmentable client registry. Extensions add their own client
|
||||
* instance types via TypeScript declaration merging:
|
||||
*
|
||||
* declare module '@heyputer/backend/clients/types' {
|
||||
* interface IExtensionClientInstances {
|
||||
* myClient: MyClient;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Augmentations flow into `this.clients` everywhere it's typed (PuterStore,
|
||||
* PuterService, PuterController, PuterDriver) and into the
|
||||
* `extension.import('client')` proxy.
|
||||
* Extension-augmentable client registry. Extensions add typed keys by
|
||||
* declaration-merging this interface from `@heyputer/backend/clients/types`;
|
||||
* the result is what `this.clients` and `extension.import('client')` see. The
|
||||
* same pattern applies to the store, service, driver and controller
|
||||
* registries.
|
||||
*/
|
||||
export interface IExtensionClientInstances {
|
||||
/**
|
||||
* Open index signature so reads of extension-only client keys return
|
||||
* `unknown` instead of a type error. Concrete declaration-merged keys
|
||||
* override this for that name.
|
||||
*/
|
||||
/** Unmerged extension keys read as `unknown` rather than erroring. */
|
||||
[key: string]: unknown;
|
||||
|
||||
/**
|
||||
* Optional ClickHouse analytics client. Absent by default — a production
|
||||
* deployment registers it via an extension to speed up the app-stats path
|
||||
* at scale (see {@link ClickhouseClient}). Always branch on its presence and
|
||||
* fall back to SQL.
|
||||
* Registered by an extension; absent by default, so branch on it and fall
|
||||
* back to SQL.
|
||||
*/
|
||||
clickhouse?: ClickhouseClient;
|
||||
}
|
||||
|
||||
@@ -186,14 +186,13 @@ export class AppController extends PuterController {
|
||||
|
||||
// POST /rao — record a recent app open. When an app-under-user
|
||||
// actor calls this, the app id is already on the token — clients
|
||||
// don't re-send it in the body. Fall back to `actor.app.uid`
|
||||
// don't re-send it in the body. Fall back to the acting app's uid
|
||||
// before 400-ing for a missing body field.
|
||||
//
|
||||
// Authorization: only two callers are trusted to report opens —
|
||||
// 1. a root user actor (plain session, no `.app` and no access
|
||||
// token), e.g. the GUI launching apps on behalf of the user;
|
||||
// 2. the app-under-user actor for the app being reported, i.e.
|
||||
// `actor.app.uid === app_uid`.
|
||||
// 2. the app-under-user actor for the app being reported.
|
||||
// Everything else — access tokens (regardless of issuer), asset
|
||||
// tokens, app actors reporting for a *different* app — is denied,
|
||||
// otherwise any authenticated party could inflate another app's
|
||||
@@ -208,11 +207,11 @@ export class AppController extends PuterController {
|
||||
async (req, res) => {
|
||||
const actor = req.actor;
|
||||
const bodyAppUid = req.body?.app_uid;
|
||||
const actorAppUid = actor?.app?.uid;
|
||||
const callerAppUid = actor?.effectiveApp?.uid;
|
||||
const app_uid =
|
||||
typeof bodyAppUid === 'string' && bodyAppUid.length > 0
|
||||
? bodyAppUid
|
||||
: actorAppUid;
|
||||
: callerAppUid;
|
||||
if (!app_uid || typeof app_uid !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `app_uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -231,7 +230,7 @@ export class AppController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
if (isAppActor(actor) && app_uid !== actorAppUid) {
|
||||
if (isAppActor(actor) && app_uid !== callerAppUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'App actors can only report opens for their own app',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -411,10 +411,10 @@ describe('AppController POST /rao', () => {
|
||||
const app = await createApp(owner.actor);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
const actorWithApp: unknown = {
|
||||
const actorWithApp: unknown = makeActor({
|
||||
...owner.actor,
|
||||
app: { uid: app.uid },
|
||||
};
|
||||
});
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
@@ -901,10 +901,10 @@ describe('AppController POST /rao actor gating', () => {
|
||||
const { actor } = await makeUser();
|
||||
const own = await createApp(actor);
|
||||
const other = await createApp(actor);
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
...actor,
|
||||
app: { uid: own.uid as string },
|
||||
} as unknown as Actor;
|
||||
} as unknown as Actor);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
* lets an app revoke a read-URL token it minted itself, without touching the
|
||||
* account-only `/auth/revoke-access-token`.
|
||||
*/
|
||||
describe('revoke-own-access-token over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const tokenReadStatus = async (fileUid: string, token: string) => {
|
||||
const url = new URL('/token-read', env.apiOrigin);
|
||||
url.searchParams.set('uid', fileUid);
|
||||
url.searchParams.set('token', token);
|
||||
return (await fetch(url)).status;
|
||||
};
|
||||
|
||||
/** A file with real content in `owner`'s home, readable by uid. */
|
||||
const makeFile = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const content = Buffer.from(`revoke-http-${crypto.randomUUID()}`);
|
||||
const path = `/${owner.username}/Documents/${crypto.randomUUID()}.txt`;
|
||||
await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
|
||||
return { uid: entry!.uid };
|
||||
};
|
||||
|
||||
/** An app of `owner`'s, with a token and read reach over `file`. */
|
||||
const makeApp = async (
|
||||
owner: { username: string },
|
||||
file: { uid: string },
|
||||
) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `auth-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Auth app',
|
||||
index_url: `https://auth-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
env.server.services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
),
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
return { ...app, token };
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
owner.token,
|
||||
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
/** Mints a read-URL token the way `getReadURL()` does. */
|
||||
const mintReadToken = async (callerToken: string, fileUid: string) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
callerToken,
|
||||
{ permissions: [`fs:${fileUid}:read`], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
it('lets an app revoke a read token it minted itself', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s a second app of the same user, and the URL still works', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const otherApp = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
otherApp.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets the account session revoke an app-minted token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s another user entirely', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
env.users.other.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('404s an app presenting an account-issued token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
// Minted with the owner's own session, so it carries no `app_uid`.
|
||||
const accountToken = await mintReadToken(owner.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: accountToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, accountToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses a scoped access token as caller', async () => {
|
||||
const owner = env.users.user;
|
||||
const scoped = await makeScopedToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
scoped,
|
||||
{ token: scoped },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('rejects a non-JWT string with token_invalid', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: 'not-a-jwt' },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'token_invalid' });
|
||||
});
|
||||
|
||||
it('still requires `token` in the body', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{},
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
/** A second personal API token for `owner`, minted the way the dashboard does. */
|
||||
const mintPersonalToken = async (owner: { token: string }) => {
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(actor!, [
|
||||
[FULL_API_ACCESS],
|
||||
]);
|
||||
};
|
||||
|
||||
const whoamiStatus = async (token: string) =>
|
||||
(await call('GET', '/whoami', token)).status;
|
||||
|
||||
it('refuses to revoke a personal API token, even for its own account', async () => {
|
||||
const owner = env.users.user;
|
||||
const pat = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: pat },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(pat)).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a personal API token from revoking a sibling one', async () => {
|
||||
const owner = env.users.user;
|
||||
const sibling = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: sibling },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(sibling)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a personal API token revoke its account`s read token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
// Access tokens can't mint, so the account session creates the URL.
|
||||
const readToken = await mintReadToken(owner.token, file.uid);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('resolves a second revoke of the same token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Signup bonus codes through the real controller: the check route, the signup
|
||||
* hand-off to `puter.signup-bonus.validate`, and the awaited
|
||||
* `user.card-verified` a grant on card verification depends on. Tests stand in
|
||||
* for the extension with shared listeners (EventClient has no `off()`).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { EventMap } from '../../clients/event/types.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let controller: any;
|
||||
|
||||
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
|
||||
|
||||
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
|
||||
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
|
||||
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardVerified: (() => Promise<void>) | null = null;
|
||||
const heardSignupSuccess: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.auth;
|
||||
const events = server.clients.event;
|
||||
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
|
||||
events.on('puter.signup-bonus.validate', (_k, data) =>
|
||||
onBonusValidate?.(data),
|
||||
);
|
||||
events.on('puter.signup.validate', (_k, data) =>
|
||||
onSignupValidate?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('puter.signup.success', (_k, data) => {
|
||||
heardSignupSuccess.push(data as Record<string, unknown>);
|
||||
});
|
||||
events.on('puter.card-verification.confirm', (_k, data) =>
|
||||
onCardConfirm?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('user.card-verified' as never, async () => {
|
||||
await onCardVerified?.();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onBonusCheck = null;
|
||||
onBonusValidate = null;
|
||||
onSignupValidate = null;
|
||||
onCardConfirm = null;
|
||||
onCardVerified = null;
|
||||
});
|
||||
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** A check listener that finds the code open, as an extension would. */
|
||||
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
|
||||
data.valid = true;
|
||||
data.display = { title: 'T', description: 'D' };
|
||||
};
|
||||
|
||||
const makeReq = (
|
||||
body: Record<string, unknown> = {},
|
||||
extra: { actor?: Actor; ip?: string } = {},
|
||||
) => ({
|
||||
body,
|
||||
headers: {},
|
||||
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
ip: extra.ip ?? '127.0.0.1',
|
||||
params: {},
|
||||
actor: extra.actor,
|
||||
});
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(b: unknown) {
|
||||
this.body = b;
|
||||
return this;
|
||||
},
|
||||
cookie() {
|
||||
return this;
|
||||
},
|
||||
clearCookie() {
|
||||
return this;
|
||||
},
|
||||
send() {
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
const signupBody = (extra: Record<string, unknown> = {}) => {
|
||||
const username = `b_${uniq()}`;
|
||||
return {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const signup = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(makeReq(body), res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const findUser = (username: unknown) =>
|
||||
server.stores.user.getByUsername(username as string, { force: true });
|
||||
|
||||
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
|
||||
const waitForSuccess = async (userUuid: string) => {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error('puter.signup.success never arrived');
|
||||
};
|
||||
|
||||
// -- POST /signup/bonus-code/check -----------------------------------
|
||||
|
||||
describe('AuthController.handleSignupBonusCheck', () => {
|
||||
const check = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignupBonusCheck(makeReq(body), res);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
it('answers invalid when no extension is listening', async () => {
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the display and requirements a listener fills in', async () => {
|
||||
let seen: EventMap['puter.signup-bonus.check'] | null = null;
|
||||
onBonusCheck = (data) => {
|
||||
seen = { ...data };
|
||||
data.valid = true;
|
||||
data.display = { title: '3 months of Basic', description: 'Free' };
|
||||
data.requirements = { phone: true, card: false };
|
||||
};
|
||||
expect(
|
||||
await check({
|
||||
bonusCode: 'Startup3M-ABCD1234',
|
||||
fingerprint: 'fp1',
|
||||
}),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
display: { title: '3 months of Basic', description: 'Free' },
|
||||
requirements: { phone: true, card: false },
|
||||
});
|
||||
// Listeners only ever see the canonical form.
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup3mabcd1234',
|
||||
fingerprint: 'fp1',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an opaque reason for a refused code', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.reason = 'ended';
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
reason: 'ended',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats valid without display as invalid', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.valid = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers a malformed code without consulting listeners', async () => {
|
||||
let called = false;
|
||||
onBonusCheck = () => {
|
||||
called = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
controller.handleSignupBonusCheck(
|
||||
makeReq({ bonusCode: 42 }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// -- POST /signup with bonusCode -------------------------------------
|
||||
|
||||
describe('AuthController.handleSignup with a bonus code', () => {
|
||||
it('refuses the signup when nothing accepts the code', async () => {
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a dead code before the abuse gate records the attempt', async () => {
|
||||
let validated = false;
|
||||
onSignupValidate = () => {
|
||||
validated = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(validated).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses a code that passed the check but is refused after the gate', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a malformed code before any listener runs', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: ['startup3m'] })),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('applies the requirements an accepted code raises and reports the code', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
|
||||
onBonusValidate = (data) => {
|
||||
seen = { ...data };
|
||||
data.accepted = true;
|
||||
data.requires_card_verification = true;
|
||||
};
|
||||
const body = signupBody({
|
||||
bonusCode: 'STARTUP6M-abcd1234',
|
||||
fingerprint: 'fp-bonus',
|
||||
});
|
||||
const res = await signup(body);
|
||||
expect(
|
||||
(res.body as { user: Record<string, unknown> }).user,
|
||||
).toMatchObject({ requires_card_verification: true });
|
||||
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup6mabcd1234',
|
||||
email: body.email,
|
||||
fingerprint: 'fp-bonus',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_card_verification)).toBe(true);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(false);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success.bonus_code).toBe('startup6mabcd1234');
|
||||
});
|
||||
|
||||
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
onSignupValidate = (data) => {
|
||||
data.requires_phone_verification = true;
|
||||
};
|
||||
onBonusValidate = (data) => {
|
||||
expect(data.requires_phone_verification).toBe(true);
|
||||
data.accepted = true;
|
||||
data.requires_phone_verification = false;
|
||||
};
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(true);
|
||||
});
|
||||
|
||||
it('never offers the code for a signup the abuse harness blocked', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let called = false;
|
||||
onSignupValidate = (data) => {
|
||||
data.allow = false;
|
||||
data.message = 'Signup blocked';
|
||||
};
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the success event without a code when none was presented', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success).not.toHaveProperty('bonus_code');
|
||||
});
|
||||
|
||||
it('ignores a bonus code on a temp signup', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
|
||||
true,
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -- /card-verification/confirm awaits user.card-verified ------------
|
||||
|
||||
describe('AuthController.handleCardVerificationConfirm', () => {
|
||||
it('finishes user.card-verified listeners before responding', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const created = await findUser(body.username);
|
||||
await server.stores.user.update(created!.id, {
|
||||
requires_card_verification: 1,
|
||||
});
|
||||
const actor = {
|
||||
user: {
|
||||
id: created!.id,
|
||||
uuid: created!.uuid,
|
||||
username: created!.username,
|
||||
email: created!.email ?? null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
onCardConfirm = (data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
data.fingerprint = 'fp_card_1';
|
||||
};
|
||||
let listenerDone = false;
|
||||
onCardVerified = async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
listenerDone = true;
|
||||
};
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ card_verified: true });
|
||||
expect(listenerDone).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { requireUserActorGate } from '../../core/http/middleware/gates.js';
|
||||
@@ -393,8 +393,7 @@ describe('concurrent claims on one email address', () => {
|
||||
expect(await countOwners(email)).toBe(1);
|
||||
|
||||
const rejected = results.find((r) => r.status === 'rejected') as
|
||||
| PromiseRejectedResult
|
||||
| undefined;
|
||||
PromiseRejectedResult | undefined;
|
||||
expect(rejected?.reason).toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
@@ -706,6 +705,79 @@ describe('AuthController.handleSignup', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects 400 when the home path is already occupied by a foreign row', async () => {
|
||||
const parked = `s_${uniq()}`;
|
||||
const occupantUsername = `s_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: occupantUsername,
|
||||
email: `${occupantUsername}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
const occupant = await server.stores.user.getByUsername(
|
||||
occupantUsername,
|
||||
);
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
occupant!.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
await expect(
|
||||
server.stores.user.getByUsername(parked, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects 400 when only a foreign row sits under the home path', async () => {
|
||||
const parked = `s_${uniq()}`;
|
||||
const occupantUsername = `s_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: occupantUsername,
|
||||
email: `${occupantUsername}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupantUsername}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
await expect(
|
||||
server.stores.user.getByUsername(parked, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects reserved usernames (e.g. "admin")', async () => {
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
@@ -2357,10 +2429,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
);
|
||||
appUid = app.uid;
|
||||
appId = app.id;
|
||||
appActor = {
|
||||
appActor = makeActor({
|
||||
user: userActor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
} as unknown as Actor;
|
||||
});
|
||||
});
|
||||
|
||||
const grant = (body: Record<string, unknown>) =>
|
||||
@@ -2404,11 +2476,11 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
const path = (name: string) => `/${user.username}/${name}`;
|
||||
const rand = () => uuidv4().slice(0, 6);
|
||||
|
||||
it('creates a missing directory, grants it, and a subsequent check reports it held', async () => {
|
||||
it('creates a missing directory by default, grants it, and a subsequent check reports it held', async () => {
|
||||
const p = path(`.mail-${rand()}`);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
|
||||
|
||||
await grant({ permission: `fs:${p}:write`, create: true });
|
||||
await grant({ permission: `fs:${p}:write` });
|
||||
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(p);
|
||||
expect(entry?.isDir).toBe(true);
|
||||
@@ -2454,10 +2526,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('without `create`, a missing path still 404s and nothing is created', async () => {
|
||||
it('`create: false` opts out: a missing path 404s and nothing is created', async () => {
|
||||
const p = path(`missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${p}:write` }),
|
||||
grant({ permission: `fs:${p}:write`, create: false }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
@@ -2476,7 +2548,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
expect(parentEntry?.isDir).toBe(true);
|
||||
const leafEntry = await server.stores.fsEntry.getEntryByPath(leaf);
|
||||
expect(leafEntry?.isDir).toBe(true);
|
||||
expect(await grantedPermissions()).toContain(`fs:${leafEntry!.uuid}:write`);
|
||||
expect(await grantedPermissions()).toContain(
|
||||
`fs:${leafEntry!.uuid}:write`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back only the leaf, not auto-created intermediate directories, when a later phase fails', async () => {
|
||||
@@ -2525,18 +2599,26 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
await expect(
|
||||
grant({ permission: `fs:${appDataPath}:write`, create: true }),
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(appDataPath)).toBeFalsy();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(appDataSubdir)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(appDataPath),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(appDataSubdir),
|
||||
).toBeFalsy();
|
||||
|
||||
const trashPath = path(`Trash/x-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${trashPath}:write`, create: true }),
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(trashPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(trashPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('rejects a path more than the depth limit below home, creating nothing', async () => {
|
||||
const deep = path(Array.from({ length: 17 }, () => `d-${rand()}`).join('/'));
|
||||
const deep = path(
|
||||
Array.from({ length: 17 }, () => `d-${rand()}`).join('/'),
|
||||
);
|
||||
await expect(
|
||||
grant({ permission: `fs:${deep}:write`, create: true }),
|
||||
).rejects.toMatchObject({
|
||||
@@ -2624,7 +2706,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
|
||||
const p = path(`manage-missing-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `manage:fs:${p}:write`, create: true }),
|
||||
grant({ permission: `manage:fs:${p}:write` }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
@@ -2639,24 +2721,33 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
createMissingParents: true,
|
||||
});
|
||||
const missingPath = path(`.newmail-${rand()}`);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
|
||||
const mkdirSpy = vi.spyOn(server.services.fs, 'mkdir');
|
||||
const touchSpy = vi.spyOn(server.services.fs, 'touch');
|
||||
try {
|
||||
await grant({
|
||||
permissions: [`fs:${existingPath}:write`, `fs:${missingPath}:write`],
|
||||
permissions: [
|
||||
`fs:${existingPath}:write`,
|
||||
`fs:${missingPath}:write`,
|
||||
],
|
||||
create: true,
|
||||
});
|
||||
// Only the missing one is actually created.
|
||||
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
touchSpy.mockRestore();
|
||||
}
|
||||
|
||||
const existingEntry = await server.stores.fsEntry.getEntryByPath(existingPath);
|
||||
const createdEntry = await server.stores.fsEntry.getEntryByPath(missingPath);
|
||||
const existingEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(existingPath);
|
||||
const createdEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath);
|
||||
expect(createdEntry?.isDir).toBe(true);
|
||||
const perms = await grantedPermissions();
|
||||
expect(perms).toContain(`fs:${existingEntry!.uuid}:write`);
|
||||
@@ -2673,7 +2764,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
permissions: [`fs:${p}:write`, `fs:${p}:read`],
|
||||
create: true,
|
||||
});
|
||||
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
|
||||
).toBe(1);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
touchSpy.mockRestore();
|
||||
@@ -2694,7 +2787,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
expect(await server.stores.fsEntry.getEntryByPath(goodPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(goodPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('rolls back what phase B created when phase C fails afterward', async () => {
|
||||
@@ -2726,7 +2821,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
|
||||
// Phase B created the directory; phase C then failed on the second
|
||||
// entry, and the rollback undid it.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('grant/revoke parity: revoking after a `create` grant removes the row and check no longer holds it', async () => {
|
||||
@@ -2738,7 +2835,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
expect(await checkAsApp(`fs:${p}:write`)).toBe(false);
|
||||
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(p);
|
||||
expect(await grantedPermissions()).not.toContain(`fs:${entry!.uuid}:write`);
|
||||
expect(await grantedPermissions()).not.toContain(
|
||||
`fs:${entry!.uuid}:write`,
|
||||
);
|
||||
});
|
||||
|
||||
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
|
||||
@@ -2754,7 +2853,12 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [`fs:${missingPath}:write`, heldPermission] },
|
||||
{
|
||||
permissions: [
|
||||
`fs:${missingPath}:write`,
|
||||
heldPermission,
|
||||
],
|
||||
},
|
||||
{ actor: appActor },
|
||||
),
|
||||
res,
|
||||
@@ -2764,7 +2868,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
const body = res.body as { permissions: Record<string, boolean> };
|
||||
expect(body.permissions[`fs:${missingPath}:write`]).toBe(false);
|
||||
expect(body.permissions[heldPermission]).toBe(true);
|
||||
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(missingPath),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('ignores `create` on a non-fs permission and grants it normally', async () => {
|
||||
@@ -2777,7 +2883,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
|
||||
for (const badCreate of ['sock', 1, {}]) {
|
||||
const p = path(`.badcreate-${rand()}`);
|
||||
await expect(
|
||||
grant({ permission: `fs:${p}:write`, create: badCreate as never }),
|
||||
grant({
|
||||
permission: `fs:${p}:write`,
|
||||
create: badCreate as never,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
@@ -2934,6 +3043,35 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
expect(typeof body.token).toBe('string');
|
||||
});
|
||||
|
||||
// What decides whether a cancelled "Sign in with Puter" still hands over a token.
|
||||
it('check-app reports an app the user never opened as unauthorized, with no token', async () => {
|
||||
const untouched = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; id: number }>
|
||||
)(
|
||||
{
|
||||
name: `ca-${uuidv4()}`,
|
||||
title: 'Never opened',
|
||||
index_url: 'https://never-opened.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckApp(
|
||||
makeReq({ app_uid: untouched.uid }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
app_uid: untouched.uid,
|
||||
authenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
|
||||
// Create a brand-new actor with no app-related history so the
|
||||
// permission scan can't cache-hit anything from prior tests, AND
|
||||
@@ -2996,16 +3134,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
authenticated: boolean;
|
||||
token?: string;
|
||||
};
|
||||
expect(body.app_uid).toBe(otherApp.uid);
|
||||
expect(typeof body.authenticated).toBe('boolean');
|
||||
// Whether `authenticated` is true depends on the user's full
|
||||
// permission set (default group, owned-app implicits, etc.) — this
|
||||
// test only pins the response *shape*, since the substantive case
|
||||
// (`authenticated: true` after a paired get-user-app-token) is
|
||||
// covered by the test above.
|
||||
if (!body.authenticated) {
|
||||
expect(body.token).toBeUndefined();
|
||||
}
|
||||
expect(body).toEqual({ app_uid: otherApp.uid, authenticated: false });
|
||||
});
|
||||
|
||||
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
|
||||
@@ -3063,6 +3192,60 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
expect(bootstrapped).toBeTruthy();
|
||||
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
|
||||
});
|
||||
|
||||
it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => {
|
||||
const subdomain = `sd-${uuidv4().slice(0, 8)}`;
|
||||
await server.stores.subdomain.create({ userId: user.id, subdomain });
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq(
|
||||
{ origin: `https://${subdomain}.host.puter.localhost` },
|
||||
{ actor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = res.body as { token: string; app_uid: string };
|
||||
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
|
||||
expect(bootstrapped?.index_url).toBe(
|
||||
`http://${subdomain}.site.puter.localhost`,
|
||||
);
|
||||
|
||||
const res2 = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq(
|
||||
{ origin: `https://${subdomain}.app.puter.localhost` },
|
||||
{ actor },
|
||||
),
|
||||
res2,
|
||||
),
|
||||
);
|
||||
expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid);
|
||||
});
|
||||
|
||||
it('supports browser extension origins in handleGetUserAppToken', async () => {
|
||||
// Random id: a pre-existing row for this origin would resolve through
|
||||
// the canonical lookup and never exercise the bootstrap path.
|
||||
const origin = `chrome-extension://${uuidv4()}`;
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq({ origin }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = res.body as { token: string; app_uid: string };
|
||||
expect(body.app_uid).toBe(
|
||||
`app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
|
||||
);
|
||||
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
|
||||
expect(bootstrapped?.index_url).toBe(origin);
|
||||
// Proves the row came from the bootstrap path, not an earlier test.
|
||||
expect(bootstrapped?.description).toMatch(/^App created from origin /);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Access tokens: create + revoke ─────────────────────────────────
|
||||
@@ -3879,8 +4062,8 @@ describe('AuthController.handleConfirmPhone', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('short-circuits to verified when the gate is not set (no Prelude call)', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
it('short-circuits to verified when a verified number is on file (no Prelude call)', async () => {
|
||||
const { actor } = await makeUserAndActor({ phone: '+14155550123' });
|
||||
const checkVerification = vi.fn();
|
||||
await withPrelude(stubPrelude({ checkVerification }), async () => {
|
||||
const res = makeRes();
|
||||
@@ -3893,6 +4076,34 @@ describe('AuthController.handleConfirmPhone', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies for real when the gate was never set but no number is on file', async () => {
|
||||
// A route requiring a verified phone sends never-flagged users here;
|
||||
// answering "verified" on the clear flag alone would store nothing and
|
||||
// leave that route refusing them.
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
await server.stores.kv.set({
|
||||
key: `phone-verify-pending:${user.id}`,
|
||||
value: '+14155550123',
|
||||
});
|
||||
const checkVerification = vi.fn(async () => ({ status: 'success' }));
|
||||
await withPrelude(stubPrelude({ checkVerification }), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleConfirmPhone(
|
||||
makeReq({ code: '123456' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ phone_verified: true });
|
||||
});
|
||||
expect(checkVerification).toHaveBeenCalledWith(
|
||||
'+14155550123',
|
||||
'123456',
|
||||
);
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.phone).toBe('+14155550123');
|
||||
});
|
||||
|
||||
it('throws 400 when the gate is set but no phone is on file', async () => {
|
||||
const { actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
@@ -4970,6 +5181,102 @@ describe('AuthController.handleSaveAccount address conflicts', () => {
|
||||
expect(untouched!.email).toBeNull();
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to promote a temp account when the home path is already occupied', async () => {
|
||||
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { user: occupant } = await makeUserAndActor();
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
occupant.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
|
||||
const tempRes = makeRes();
|
||||
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
|
||||
const tempUser = (
|
||||
tempRes.body as { user: { username: string; uuid: string } }
|
||||
).user;
|
||||
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
|
||||
const actor = {
|
||||
user: {
|
||||
id: tempRow!.id,
|
||||
uuid: tempRow!.uuid,
|
||||
username: tempRow!.username,
|
||||
email: null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleSaveAccount(
|
||||
makeReq(
|
||||
{
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'another-strong-password',
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const untouched = await server.stores.user.getById(tempRow!.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.username).toBe(tempRow!.username);
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to promote a temp account when only a foreign row sits under the home path', async () => {
|
||||
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { user: occupant } = await makeUserAndActor();
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupant.username}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
|
||||
const tempRes = makeRes();
|
||||
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
|
||||
const tempUser = (
|
||||
tempRes.body as { user: { username: string; uuid: string } }
|
||||
).user;
|
||||
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
|
||||
const actor = {
|
||||
user: {
|
||||
id: tempRow!.id,
|
||||
uuid: tempRow!.uuid,
|
||||
username: tempRow!.username,
|
||||
email: null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleSaveAccount(
|
||||
makeReq(
|
||||
{
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'another-strong-password',
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const untouched = await server.stores.user.getById(tempRow!.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.username).toBe(tempRow!.username);
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Password recovery flow ──────────────────────────────────────────
|
||||
@@ -5006,6 +5313,34 @@ describe('AuthController password recovery', () => {
|
||||
expect(after!.pass_recovery_token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
|
||||
// The seat's address is admin-supplied and unverified; recovery there
|
||||
// would be a takeover channel. Its recovery is the admin's reset.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleSendPassRecoveryEmail(
|
||||
makeReq({ username: seat.username }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { message: string }).message).toMatch(
|
||||
/If that account exists/i,
|
||||
);
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.pass_recovery_token).toBeFalsy();
|
||||
});
|
||||
|
||||
it('verify-pass-recovery-token: 400 on missing token', async () => {
|
||||
await expect(
|
||||
controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()),
|
||||
@@ -5253,6 +5588,57 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('change-email: 403 for an account its team provisioned', async () => {
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeEmail(
|
||||
makeReq(
|
||||
{ new_email: `moved_${uniq()}@example.com` },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('change-username: 403 for an account its team provisioned', async () => {
|
||||
// The console lists members by username and the audit log records them
|
||||
// by it; a self-service rename would desync both.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
// addMember refuses to adopt an account that has one.
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: `r_${uniq()}` }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(seat.username);
|
||||
});
|
||||
|
||||
it('change-username: persists the rename and emits user.username-changed', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const newUsername = `r_${uniq()}`;
|
||||
@@ -5287,6 +5673,62 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('change-username: 400 when the home path is already occupied, before the rename', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
// A name no account holds, whose home path a stray row does. This is
|
||||
// the shape of legacy drift; it used to leave two roots at one path.
|
||||
const parked = `p_${uniq()}`;
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
user.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
const { user: mover, actor: moverActor } = await makeUserAndActor();
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: parked }, { actor: moverActor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
// The username claim is refused whole: the user row keeps its name.
|
||||
const after = await server.stores.user.getById(mover.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(mover.username);
|
||||
void actor;
|
||||
});
|
||||
|
||||
it('change-username: 400 when only a foreign row sits under the target home path', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
// Nothing sits at `/parked` itself — only a leftover child row does,
|
||||
// the shape a partial cascade or a user-scoped cleanup leaves behind.
|
||||
const parked = `p_${uniq()}`;
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${user.username}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
const { user: mover, actor: moverActor } = await makeUserAndActor();
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: parked }, { actor: moverActor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const after = await server.stores.user.getById(mover.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(mover.username);
|
||||
});
|
||||
|
||||
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
@@ -5686,10 +6128,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
const granted = `user:${user.uuid}:email:read`;
|
||||
const ungranted = `apps-of-user:${user.uuid}:read`;
|
||||
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
} as unknown as Actor;
|
||||
});
|
||||
const before = makeRes();
|
||||
await inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
@@ -5729,6 +6171,152 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// What lets a launch settle a consent prompt it holds no app token for.
|
||||
it('check-permissions: `app_uid` answers for that app, not for the asking user', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpa-${uuidv4()}`,
|
||||
title: 'TestCheckPermsAppUid',
|
||||
index_url: 'https://check-perms-uid.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const permission = `user:${user.uuid}:email:read`;
|
||||
|
||||
// Held by the user, so asking as the user would answer `true`.
|
||||
const asUser = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [permission] }, { actor }),
|
||||
asUser,
|
||||
),
|
||||
);
|
||||
expect(asUser.body).toEqual({ permissions: { [permission]: true } });
|
||||
|
||||
const before = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid: app.uid },
|
||||
{ actor },
|
||||
),
|
||||
before,
|
||||
),
|
||||
);
|
||||
expect(before.body).toEqual({ permissions: { [permission]: false } });
|
||||
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
const after = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid: app.uid },
|
||||
{ actor },
|
||||
),
|
||||
after,
|
||||
),
|
||||
);
|
||||
expect(after.body).toEqual({ permissions: { [permission]: true } });
|
||||
});
|
||||
|
||||
it('check-permissions: an app cannot ask about another app, and an unknown app 404s', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpx-${uuidv4()}`,
|
||||
title: 'TestCheckPermsCrossApp',
|
||||
index_url: 'https://check-perms-x.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const appActor = makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
|
||||
await expect(
|
||||
inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: app.uid,
|
||||
},
|
||||
{ actor: appActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: { not: 'a string' } as unknown as string,
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// Falling through to the user here would answer `true` for any file they own.
|
||||
it('check-permissions: a present-but-empty `app_uid` is refused, not read as "no app"', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const permission = `user:${user.uuid}:email:read`;
|
||||
|
||||
for (const app_uid of ['', null]) {
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
|
||||
// Omitted entirely still means "what do I hold?".
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [permission] }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({ permissions: { [permission]: true } });
|
||||
});
|
||||
|
||||
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
@@ -6487,6 +7075,30 @@ describe('AuthController.handleDeleteOwnUser', () => {
|
||||
expect(after).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses, and keeps the row, for an account its team provisioned', async () => {
|
||||
// The team is billed for the seat and closing it is theirs to do, from
|
||||
// the console that keeps the audit trail.
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { user: seat, actor } = await makeUserAndActor();
|
||||
const team = await server.stores.team.create({
|
||||
ownerUserId: owner.id,
|
||||
name: 'Acme',
|
||||
});
|
||||
await server.stores.user.update(seat.id, { password: null });
|
||||
await server.stores.team.addMember(team.uid, seat.id, {
|
||||
orgOwned: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
|
||||
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
|
||||
// the sqlite ones the test harness runs — add it so the delete path
|
||||
|
||||
@@ -28,7 +28,12 @@ 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 {
|
||||
hasVerifiedCard,
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
signStepUpToken,
|
||||
@@ -61,6 +66,7 @@ import {
|
||||
SEND_PHONE_RATE_LIMIT,
|
||||
SEND_PHONE_RATE_WINDOW_MS,
|
||||
cardFallbackAfterAttempts,
|
||||
cardFallbackDepsFrom,
|
||||
cardFallbackFlagKey,
|
||||
isCardFallbackEligible,
|
||||
isCardFallbackEnabled,
|
||||
@@ -70,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { parsePhone } from '../../util/phone.js';
|
||||
import {
|
||||
bonusCodeInvalidError,
|
||||
checkSignupBonus,
|
||||
isAbsentBonusCode,
|
||||
normalizeBonusCode,
|
||||
validateSignupBonus,
|
||||
} from '../../util/signupBonus.js';
|
||||
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
@@ -104,6 +117,8 @@ const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// crafted request from turning a single grant call into a bulk write.
|
||||
const MAX_PERMISSIONS_PER_REQUEST = 16;
|
||||
const DISPATCH_ID_MAX_LENGTH = 128;
|
||||
// One name for the flag, so the write and the read cannot drift apart.
|
||||
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
|
||||
|
||||
// -- Post-login route limits -----------------------------------------
|
||||
//
|
||||
@@ -125,6 +140,14 @@ const CREDENTIAL_MINT_LIMIT = {
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/** Revoking is cheap and undoing a mistake shouldn't fight the mint budget. */
|
||||
const ACCESS_TOKEN_REVOKE_LIMIT = {
|
||||
scope: 'auth-access-token-revoke',
|
||||
limit: 60,
|
||||
window: 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
|
||||
@@ -196,20 +219,6 @@ const SESSION_LIMIT = {
|
||||
// enough to cover the typical support round-trip.
|
||||
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Whether this account has already been through the card flow. Both halves
|
||||
* matter: the pending flag being clear only says nobody asked, while the
|
||||
* fingerprint is the artifact a completed check leaves behind. A route that
|
||||
* requires a verified card (`requireCardVerified`) sends users here with the
|
||||
* flag clear and no card on file, and answering "already verified" on the flag
|
||||
* alone would bounce them between a dialog that reports success and a route
|
||||
* that keeps refusing.
|
||||
*/
|
||||
const cardAlreadyVerified = (user: {
|
||||
card_fingerprint?: string | null;
|
||||
requires_card_verification?: boolean;
|
||||
}): boolean =>
|
||||
Boolean(user.card_fingerprint) && !user.requires_card_verification;
|
||||
export const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
@@ -813,6 +822,18 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the shape is judged here; whether the code is honored is up to
|
||||
// `puter.signup-bonus.validate`, after the abuse checks below.
|
||||
let bonusCode: string | null = null;
|
||||
if (!is_temp && !isAbsentBonusCode(body.bonusCode)) {
|
||||
if (typeof body.bonusCode !== 'string')
|
||||
throw new HttpError(400, 'bonusCode must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
bonusCode = normalizeBonusCode(body.bonusCode);
|
||||
if (!bonusCode) throw bonusCodeInvalidError();
|
||||
}
|
||||
|
||||
// Signup-disabled gate. Runs before the duplicate checks so a
|
||||
// disabled endpoint doesn't reveal which usernames or emails
|
||||
// exist. Claiming a pre-existing placeholder row is still
|
||||
@@ -845,6 +866,22 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ...and the same against the filesystem: a free username whose home
|
||||
// path is occupied (exactly or by leftover rows underneath it) would
|
||||
// provision a second root there, and the two trees then resolve
|
||||
// interchangeably.
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(
|
||||
body.username,
|
||||
undefined,
|
||||
{ includeDescendants: true },
|
||||
)
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate confirmed-email check. A confirmed account (any
|
||||
// credential type — password OR OIDC) on this email → reject.
|
||||
//
|
||||
@@ -877,6 +914,20 @@ export class AuthController extends PuterController {
|
||||
? null
|
||||
: await this.#resolveSignupEmailClaim(body.email);
|
||||
|
||||
// A dead code fails here rather than after the gate below, which records
|
||||
// an allowed attempt against the address as if an account followed.
|
||||
if (
|
||||
bonusCode &&
|
||||
!(
|
||||
await checkSignupBonus(this.clients.event, bonusCode, {
|
||||
ip: clientIp,
|
||||
fingerprint,
|
||||
})
|
||||
).valid
|
||||
) {
|
||||
throw bonusCodeInvalidError();
|
||||
}
|
||||
|
||||
// Extension-level validation gate. Abuse-prevention extensions
|
||||
// inspect the incoming signup and can:
|
||||
// - block it outright via `event.allow = false`
|
||||
@@ -963,18 +1014,37 @@ export class AuthController extends PuterController {
|
||||
const force_email_confirmation = Boolean(
|
||||
validateEvent.requires_email_confirmation,
|
||||
);
|
||||
const force_phone_verification =
|
||||
let force_phone_verification =
|
||||
Boolean(validateEvent.requires_phone_verification) ||
|
||||
// Test/QA switch: force the SMS gate on every signup regardless of
|
||||
// reputation (see config.always_require_phone_verification).
|
||||
Boolean(this.config.always_require_phone_verification);
|
||||
const force_card_verification = Boolean(
|
||||
let force_card_verification = Boolean(
|
||||
validateEvent.requires_card_verification ||
|
||||
// Test/QA switch: force the card gate on every signup regardless of
|
||||
// reputation (see config.always_require_card_verification).
|
||||
this.config.always_require_card_verification,
|
||||
);
|
||||
|
||||
if (bonusCode) {
|
||||
const verdict = await validateSignupBonus(
|
||||
this.clients.event,
|
||||
bonusCode,
|
||||
{
|
||||
email: body.email,
|
||||
clean_email: cleanEmail(body.email),
|
||||
ip: clientIp,
|
||||
fingerprint,
|
||||
reputation: validateEvent.reputation,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
},
|
||||
);
|
||||
if (!verdict.accepted) throw bonusCodeInvalidError();
|
||||
force_phone_verification = verdict.requiresPhoneVerification;
|
||||
force_card_verification = verdict.requiresCardVerification;
|
||||
}
|
||||
|
||||
// Prepare shared fields
|
||||
const user_uuid = uuidv4();
|
||||
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
|
||||
@@ -1195,6 +1265,7 @@ export class AuthController extends PuterController {
|
||||
// have to agree or per-IP counters are written under one
|
||||
// key and read under another.
|
||||
ip: clientIp,
|
||||
...(bonusCode ? { bonus_code: bonusCode } : {}),
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
@@ -1216,6 +1287,64 @@ export class AuthController extends PuterController {
|
||||
await this.#completeLogin(req, res, user!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a signup bonus code before the account exists, so the signup form
|
||||
* can say what it grants. Answers only valid/invalid: an extension owns the
|
||||
* codes, and with none installed every code is invalid.
|
||||
*/
|
||||
@Post('/signup/bonus-code/check', {
|
||||
rateLimit: [
|
||||
{ scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 },
|
||||
{
|
||||
scope: 'signup-bonus-check-ip',
|
||||
limit: 60,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
],
|
||||
})
|
||||
async handleSignupBonusCheck(req: Request, res: Response): Promise<void> {
|
||||
const raw = req.body?.bonusCode;
|
||||
if (typeof raw !== 'string')
|
||||
throw new HttpError(400, 'bonusCode must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
const code = normalizeBonusCode(raw);
|
||||
if (!code) {
|
||||
res.json({ valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint =
|
||||
typeof req.body?.fingerprint === 'string' &&
|
||||
req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH
|
||||
? req.body.fingerprint
|
||||
: null;
|
||||
const checkEvent = await checkSignupBonus(this.clients.event, code, {
|
||||
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
if (checkEvent.valid !== true || !checkEvent.display) {
|
||||
res.json({
|
||||
valid: false,
|
||||
...(checkEvent.reason ? { reason: checkEvent.reason } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
valid: true,
|
||||
display: {
|
||||
title: String(checkEvent.display.title),
|
||||
description: String(checkEvent.display.description),
|
||||
},
|
||||
requirements: {
|
||||
phone: checkEvent.requirements?.phone === true,
|
||||
card: checkEvent.requirements?.card === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -- Logout ------------------------------------------------------
|
||||
|
||||
@Post('/logout', {
|
||||
@@ -1621,18 +1750,7 @@ export class AuthController extends PuterController {
|
||||
* is listening on a stock build, which reads as "no card gate".
|
||||
*/
|
||||
private cardFallbackDeps(): CardFallbackDeps {
|
||||
return {
|
||||
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const statusEvent = { enabled: null as boolean | null };
|
||||
await this.clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
statusEvent,
|
||||
{},
|
||||
);
|
||||
return statusEvent.enabled;
|
||||
},
|
||||
};
|
||||
return cardFallbackDepsFrom(this.clients);
|
||||
}
|
||||
|
||||
@Post('/send-confirm-phone', {
|
||||
@@ -1898,7 +2016,12 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(404, 'User not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (!user.requires_phone_verification) {
|
||||
// Both halves, as for the card: the flag being clear only says nobody
|
||||
// asked. A route requiring a verified phone sends never-flagged users
|
||||
// here with no number on file, and answering "already verified" on
|
||||
// the flag alone would bounce them between a dialog that reports
|
||||
// success and a route that keeps refusing.
|
||||
if (hasVerifiedPhone(user)) {
|
||||
res.json({ phone_verified: true, original_client_socket_id });
|
||||
return;
|
||||
}
|
||||
@@ -2024,7 +2147,7 @@ export class AuthController extends PuterController {
|
||||
// Phone normally comes first, but the fallback lets a phone-gated user
|
||||
// in once they've exhausted SMS attempts.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
||||
if (hasVerifiedCard(user) && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
@@ -2039,6 +2162,7 @@ export class AuthController extends PuterController {
|
||||
// extension always sets it (true/false) before doing any work.
|
||||
const setupEvent = {
|
||||
user_id: user.id,
|
||||
email: user.email!,
|
||||
user_uid: user.uuid,
|
||||
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
|
||||
device_fingerprint: req.deviceFingerprint ?? null,
|
||||
@@ -2144,7 +2268,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
// Same fallback exception as setup: card may come before phone.
|
||||
const fallbackEligible = await this.isCardFallbackEligible(user);
|
||||
if (cardAlreadyVerified(user) && !fallbackEligible) {
|
||||
if (hasVerifiedCard(user) && !fallbackEligible) {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
@@ -2158,6 +2282,7 @@ export class AuthController extends PuterController {
|
||||
const confirmEvent = {
|
||||
user_id: user.id,
|
||||
user_uid: user.uuid,
|
||||
email: user.email!,
|
||||
setup_intent_id,
|
||||
enabled: null as boolean | null,
|
||||
verified: false,
|
||||
@@ -2213,8 +2338,11 @@ export class AuthController extends PuterController {
|
||||
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
||||
});
|
||||
|
||||
// Awaited like `user.phone-verified`, so whatever a listener grants on
|
||||
// verification is in place before the client refreshes. emitAndWait
|
||||
// swallows listener errors, so confirm never fails on one.
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
await this.clients.event?.emitAndWait(
|
||||
'user.card-verified' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
@@ -2227,7 +2355,7 @@ export class AuthController extends PuterController {
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — event is a side-channel signal, not load-bearing
|
||||
// ignore — listeners are best-effort
|
||||
}
|
||||
// Notify other tabs/devices for this user so they refresh + drop the gate.
|
||||
try {
|
||||
@@ -2293,6 +2421,14 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// A seat's address is admin-supplied and never verified, so whoever
|
||||
// holds that inbox could take the seat over. Its recovery channel is
|
||||
// the team admin's password reset, not this one.
|
||||
if (await this.stores.team.getOrgSeat(user.id)) {
|
||||
res.json({ message: genericMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
const pass_recovery_token = uuidv4();
|
||||
await this.stores.user.update(user.id, { pass_recovery_token });
|
||||
|
||||
@@ -2551,6 +2687,17 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
async handleChangeUsername(req: Request, res: Response): Promise<void> {
|
||||
// A provisioned account's name belongs to the team that made it: the
|
||||
// console lists its members by username and the audit log records them
|
||||
// by username, so a self-service rename would desync both.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this username. Ask a team admin to change it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const { new_username } = req.body ?? {};
|
||||
if (!new_username || typeof new_username !== 'string') {
|
||||
throw new HttpError(400, '`new_username` is required', {
|
||||
@@ -2581,6 +2728,20 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
// Before the username is written, not after: the rename below is what
|
||||
// keeps the account's files reachable, and it can't run onto a taken
|
||||
// path.
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(
|
||||
new_username,
|
||||
req.actor!.user.id!,
|
||||
{ includeDescendants: true },
|
||||
)
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor!.user.id!, {
|
||||
username: new_username,
|
||||
@@ -2617,6 +2778,16 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
async handleChangeEmail(req: Request, res: Response): Promise<void> {
|
||||
// The address is where admin-issued credentials and team notices go;
|
||||
// same reasoning as the username and deletion guards above.
|
||||
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team set this address. Ask a team admin to change it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
const { new_email } = req.body ?? {};
|
||||
if (!new_email || typeof new_email !== 'string') {
|
||||
throw new HttpError(400, '`new_email` is required', {
|
||||
@@ -2895,6 +3066,15 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(username, user.id, {
|
||||
includeDescendants: true,
|
||||
})
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
// Match raw + canonical to catch gmail-alias collisions, and
|
||||
// reject on ANY confirmed account (OIDC accounts have
|
||||
// password=null but are real) — not just password-holders.
|
||||
@@ -3251,8 +3431,8 @@ export class AuthController extends PuterController {
|
||||
/**
|
||||
* Whether a granted `fs:` path permission needs its target created before
|
||||
* the grant can resolve, and if so where and what kind. Null when there is
|
||||
* nothing to create: `create` was not requested, the entry does not name an
|
||||
* `fs:` path, it is a `manage:` grant, or the path already exists.
|
||||
* nothing to create: `create` is `false`, the entry does not name an `fs:`
|
||||
* path, it is a `manage:` grant, or the path already exists.
|
||||
*
|
||||
* Side-effect-free, like `#prepareAppDataGrant` above — a pre-grant hook
|
||||
* run in the same validation loop, so a bad entry elsewhere in the list
|
||||
@@ -3308,11 +3488,11 @@ export class AuthController extends PuterController {
|
||||
// They are deliberately not rolled back if a later phase fails, so a
|
||||
// failed grant can leave empty directories inside the user's home.
|
||||
return plan.kind === 'dir'
|
||||
? await this.services.fs.mkdir(userId, {
|
||||
? await this.services.fs.mkdir(userId!, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
})
|
||||
: await this.services.fs.touch(userId, {
|
||||
: await this.services.fs.touch(userId!, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
});
|
||||
@@ -3331,7 +3511,7 @@ export class AuthController extends PuterController {
|
||||
const userId = actor.user!.id;
|
||||
for (const entry of [...created].reverse()) {
|
||||
try {
|
||||
await this.services.fs.remove(userId, {
|
||||
await this.services.fs.remove(userId!, {
|
||||
entry,
|
||||
recursive: false,
|
||||
});
|
||||
@@ -3366,7 +3546,7 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// 400 on a bad value, before anything else runs.
|
||||
// 400 on a bad value, before anything else runs. Absent means create.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
@@ -3519,27 +3699,66 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Permission checks -------------------------------------------
|
||||
|
||||
/**
|
||||
* The caller's account acting as `appIdentifier`, resolved uid-or-name like
|
||||
* the grant handlers.
|
||||
*/
|
||||
async #appUnderUserActor(
|
||||
actor: Actor,
|
||||
appIdentifier: unknown,
|
||||
): Promise<Actor> {
|
||||
this.#validateAppPermissionParams({ app_uid: appIdentifier });
|
||||
// Sessions only: for an app, this would be a window onto its neighbours' grants.
|
||||
if (!isPlainUserActor(actor)) {
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
const app = await this.stores.app.resolveApp(appIdentifier as string);
|
||||
if (!app) {
|
||||
throw new HttpError(404, `App ${appIdentifier} does not exist`, {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
return makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/auth/check-permissions', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
||||
const { permissions } = req.body ?? {};
|
||||
const { permissions, app_uid } = req.body ?? {};
|
||||
if (!Array.isArray(permissions)) {
|
||||
throw new HttpError(400, 'Missing or invalid `permissions` array', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Present but empty must not fall through to checking the user: on their own file every `fs:` scope answers `true`.
|
||||
if (
|
||||
app_uid !== undefined &&
|
||||
(typeof app_uid !== 'string' || !app_uid)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid `app_uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// `app_uid` asks what an app of mine holds, not what I hold.
|
||||
const actor = app_uid
|
||||
? await this.#appUnderUserActor(req.actor!, app_uid)
|
||||
: req.actor!;
|
||||
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
granted = await this.services.permission.checkMany(
|
||||
req.actor!,
|
||||
unique,
|
||||
);
|
||||
granted = await this.services.permission.checkMany(actor, unique);
|
||||
} catch {
|
||||
granted = new Map<string, boolean>();
|
||||
}
|
||||
@@ -3823,11 +4042,18 @@ export class AuthController extends PuterController {
|
||||
if (!app && resolvedFromOrigin) {
|
||||
// Hosted-subdomain origins get the site owner stamped as the
|
||||
// app's creator at bootstrap; external origins stay unowned.
|
||||
// Canonical origin only, so alternate hosts share one row.
|
||||
const canonicalOrigin =
|
||||
this.services.auth.canonicalizeOrigin(origin);
|
||||
const ownerUserId =
|
||||
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
|
||||
app = await this.stores.app.createFromOrigin(app_uid, origin, {
|
||||
ownerUserId,
|
||||
});
|
||||
await this.services.auth.subdomainOwnerIdFromOrigin(
|
||||
canonicalOrigin,
|
||||
);
|
||||
app = await this.stores.app.createFromOrigin(
|
||||
app_uid,
|
||||
canonicalOrigin,
|
||||
{ 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
|
||||
@@ -3855,7 +4081,7 @@ export class AuthController extends PuterController {
|
||||
this.services.permission.grantUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
'flag:app-is-authenticated',
|
||||
APP_AUTHENTICATED_FLAG,
|
||||
{},
|
||||
{},
|
||||
);
|
||||
@@ -3939,13 +4165,15 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Check if the app is authenticated for this user
|
||||
const authenticated = await this.services.permission
|
||||
.check(
|
||||
req.actor!,
|
||||
`service:${app_uid}:ii:flag:app-is-authenticated`,
|
||||
)
|
||||
.catch(() => false);
|
||||
// The exact row, not a scan: a `service:`-shaped check falls open on the `service` root.
|
||||
const app = await this.stores.app.resolveApp(app_uid);
|
||||
const userId = req.actor!.user?.id;
|
||||
const authenticated =
|
||||
!!app?.id &&
|
||||
!!userId &&
|
||||
(await this.stores.permission
|
||||
.hasUserAppPerm(userId, app.id, APP_AUTHENTICATED_FLAG)
|
||||
.catch(() => false));
|
||||
|
||||
const result: {
|
||||
app_uid: string;
|
||||
@@ -3953,9 +4181,10 @@ export class AuthController extends PuterController {
|
||||
token?: string;
|
||||
} = { app_uid, authenticated };
|
||||
if (authenticated) {
|
||||
// The resolved uid: the token carries it as the app's identity.
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
app!.uid,
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
@@ -4028,10 +4257,36 @@ export class AuthController extends PuterController {
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an access token given as its JWT. Unlike
|
||||
* `/auth/revoke-access-token`, apps may call this; the service limits an
|
||||
* app to tokens it issued and refuses personal API tokens.
|
||||
*/
|
||||
@Post('/auth/revoke-own-access-token', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: ACCESS_TOKEN_REVOKE_LIMIT,
|
||||
})
|
||||
async handleRevokeOwnAccessToken(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { token } = req.body ?? {};
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new HttpError(400, 'Missing `token`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.services.auth.revokeOwnAccessToken(req.actor!, token);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: a leaked access token must not be able to silently
|
||||
// revoke its own siblings.
|
||||
// mandatory: a leaked access token must not be able to revoke a
|
||||
// personal API token, or revoke by raw uuid — those stay web-session-only
|
||||
// here; `/auth/revoke-own-access-token` covers revoking scoped tokens by
|
||||
// JWT and already refuses PATs itself.
|
||||
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
||||
let { tokenOrUuid } = req.body ?? {};
|
||||
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
||||
@@ -4374,6 +4629,34 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
// The team owns the account and is billed for it; only they may close
|
||||
// it, through the console that keeps the audit trail.
|
||||
if (await this.stores.team.getOrgSeat(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Your team owns this account. Ask a team admin to remove it.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// The other direction: deleting an owner sets `owner_user_id` NULL and
|
||||
// cascades their membership, leaving a team no one can administer —
|
||||
// not even an admin, since every team route resolves by membership.
|
||||
if (await this.stores.team.countOwned(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Delete the teams you own before closing this account.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// A deleted team's seats are suspended, not gone, and only its owner
|
||||
// can retire them.
|
||||
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Close the accounts your teams created before closing this one.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
@@ -4704,8 +4987,8 @@ export class AuthController extends PuterController {
|
||||
};
|
||||
try {
|
||||
await this.clients.event?.emitAndWait(
|
||||
'email.validate' as never,
|
||||
validateEvent as never,
|
||||
'email.validate',
|
||||
validateEvent,
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -4816,6 +5099,7 @@ export class AuthController extends PuterController {
|
||||
phone?: string | null;
|
||||
requires_phone_verification?: number | boolean;
|
||||
requires_card_verification?: number | boolean;
|
||||
requires_password_change?: number | boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const meta = {
|
||||
@@ -4854,6 +5138,20 @@ export class AuthController extends PuterController {
|
||||
console.warn('[auth] taskbar_items resolution failed:', e);
|
||||
}
|
||||
|
||||
// Same shape as whoami: no-reload logins store this payload as
|
||||
// window.user verbatim, and every seat restriction keys on `team`.
|
||||
let team: { uid: string; name: string | null } | undefined;
|
||||
if (this.config.teams_enabled === true) {
|
||||
try {
|
||||
const seat = await this.stores.team.getOrgSeat(user.id);
|
||||
if (seat) {
|
||||
team = { uid: seat.team_uid, name: seat.team_name ?? null };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[auth] team lookup failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Response body gets the GUI token (client never sees session token)
|
||||
res.json({
|
||||
proceed: true,
|
||||
@@ -4868,7 +5166,9 @@ export class AuthController extends PuterController {
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
requires_password_change: user.requires_password_change,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
...(team ? { team } : {}),
|
||||
taskbar_items,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,8 +47,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/webhook', { subdomain: '*' })
|
||||
async webhook(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -80,8 +79,7 @@ export class BroadcastController extends PuterController {
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
| BroadcastService
|
||||
| undefined;
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
@@ -106,6 +104,7 @@ export class BroadcastController extends PuterController {
|
||||
|
||||
const reply = await this.services.eventForward.receive(
|
||||
(req.body ?? {}) as ForwardBatch,
|
||||
verified.peerId ?? '',
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
@@ -179,6 +179,28 @@ const throwing = (payload: unknown) => () => {
|
||||
// -- Upstream status extraction --------------------------------------
|
||||
|
||||
describe('DriverController upstream error translation', () => {
|
||||
it('maps an upstream 402 to sanitized credit exhaustion', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'<html>Insufficient credits. Add more using https://openrouter.ai/settings/credits (request id: req-secret)</html>',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
message: 'AI provider out of credits',
|
||||
fields: { upstreamStatus: 402 },
|
||||
});
|
||||
expect(JSON.stringify(err)).not.toContain('http');
|
||||
expect(JSON.stringify(err)).not.toMatch(/request id|<html>/i);
|
||||
});
|
||||
|
||||
it('maps an upstream 429 to a Puter 429 with upstream_rate_limited', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isCreditExhaustion,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
@@ -120,6 +124,18 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
cause?: unknown;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
const msg = sanitizeUpstreamMessage(
|
||||
e.error?.message ?? e.message ?? 'Upstream provider error',
|
||||
);
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (isCreditExhaustion(status, upstreamCode, msg)) {
|
||||
return new HttpError(503, 'AI provider out of credits', {
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (typeof status !== 'number') {
|
||||
if (isUpstreamTimeoutError(e)) {
|
||||
const cause = e.cause as { code?: string } | undefined;
|
||||
@@ -132,10 +148,6 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
return err;
|
||||
}
|
||||
|
||||
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (status === 429) {
|
||||
return new HttpError(429, msg, {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
|
||||
@@ -287,12 +287,14 @@ export class EventsController extends PuterController {
|
||||
|
||||
// -- Events workers ------------------------------------------------
|
||||
//
|
||||
// The billable artifact a published handler set implies. Account-scoped
|
||||
// like the kv-handle surface above: this is the owner's own view of what
|
||||
// it is paying for, so — unlike the handler routes — an app token cannot
|
||||
// act here on its owner's behalf.
|
||||
// The billable artifact a published handler set implies. An account
|
||||
// session or full-access token sees every app it owns; an app sees only
|
||||
// its own worker, the same scope publish and destroy use.
|
||||
|
||||
/** GET /events/workers — the caller's own events workers, one per app. */
|
||||
/**
|
||||
* GET /events/workers — an account's events workers, one per app, or an
|
||||
* app's own worker (0 or 1 item).
|
||||
*/
|
||||
@Get('/workers', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
ORG_SEAT_FREE_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import {
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
limitFor,
|
||||
@@ -58,6 +61,23 @@ describe('the tiered subscription quotas', () => {
|
||||
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a free plan nobody enumerated to the free cap, not the paid one', () => {
|
||||
// A team seat resolves to `org_seat_free`, which no tier names. Falling
|
||||
// through to `limit` would give it more than an ordinary free account.
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe(
|
||||
tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('still holds an unresolved or paid plan to the base', () => {
|
||||
for (const tier of tiers.map(([, t]) => t)) {
|
||||
expect(limitFor(tier, null)).toBe(tier.limit);
|
||||
expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps what one app may take below what the account may hold', () => {
|
||||
for (const plan of [
|
||||
null,
|
||||
@@ -101,6 +121,34 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
describe('the delivery budgets', () => {
|
||||
it('sets KV fan-out and filter work by the namespace owner tier', () => {
|
||||
for (const [plan, matched, evaluated] of [
|
||||
[null, 512, 2048],
|
||||
[DEFAULT_FREE_SUBSCRIPTION, 128, 512],
|
||||
[DEFAULT_TEMP_SUBSCRIPTION, 128, 512],
|
||||
[ORG_SEAT_FREE_SUBSCRIPTION, 128, 512],
|
||||
] as const) {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(200);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(128);
|
||||
});
|
||||
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
FREE_SUBSCRIPTION_IDS,
|
||||
} from '../../services/metering/consts.js';
|
||||
|
||||
// -- Shared event limits ---------------------------------------------
|
||||
@@ -43,10 +44,9 @@ const userWindow = (
|
||||
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
|
||||
|
||||
/**
|
||||
* A cap that varies by plan, in the shape route gates already declare theirs
|
||||
* in: the base is what a subscribed account sees, and `bySubscription` carves
|
||||
* the free tiers out beneath it. A plan nobody enumerated falls through to the
|
||||
* base, so a new one is generous rather than accidentally throttled.
|
||||
* A plan-varying cap in route-gate shape: `limit` is what a subscribed account
|
||||
* gets, `bySubscription` carves out the free tiers, unlisted plans get the
|
||||
* base.
|
||||
*/
|
||||
export interface TieredLimit {
|
||||
limit: number;
|
||||
@@ -61,14 +61,18 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
},
|
||||
});
|
||||
|
||||
/** The cap one plan sees. An unresolved plan is held to the base. */
|
||||
/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */
|
||||
export const limitFor = (
|
||||
tier: TieredLimit,
|
||||
subscriptionId: string | null,
|
||||
): number =>
|
||||
(subscriptionId === null
|
||||
? undefined
|
||||
: tier.bySubscription[subscriptionId]) ?? tier.limit;
|
||||
): number => {
|
||||
if (subscriptionId === null) return tier.limit;
|
||||
const own = tier.bySubscription[subscriptionId];
|
||||
if (typeof own === 'number') return own;
|
||||
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
|
||||
? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit)
|
||||
: tier.limit;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a plan-tiered quota resolves to: what an account may hold, and what one
|
||||
@@ -163,16 +167,15 @@ export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
* grow forever. A temporary account holds none: the grant outlives the session
|
||||
* that made it, and no account is left to take it back.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
*
|
||||
* Below the per-account cap so that one app cannot spend an account's whole
|
||||
* budget: a handle names one namespace, so without this a second app on the
|
||||
* same account would be left with nothing.
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
@@ -252,12 +255,33 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
|
||||
// -- Dispatch fan-out ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscriptions one event may deliver to before dispatch stops and reports a
|
||||
* gap. The amplification ceiling: without it, one write costs as many
|
||||
* Subscriptions one non-KV event may deliver to before dispatch stops and
|
||||
* reports a gap. The amplification ceiling: without it, one write costs as many
|
||||
* deliveries as an account cared to register.
|
||||
*/
|
||||
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
|
||||
|
||||
/**
|
||||
* Key-value subscriptions one mutation may deliver to. A namespace owner's plan
|
||||
* sets this ceiling; the listeners' plans do not affect it.
|
||||
*/
|
||||
export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
|
||||
|
||||
/**
|
||||
* Filter evaluations a key-value mutation may spend. This leaves room for
|
||||
* non-matching rows while retaining a finite bound on dispatch work.
|
||||
*/
|
||||
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
|
||||
|
||||
/** Omit all inline KV values when the matched audience exceeds this count. */
|
||||
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
|
||||
|
||||
/**
|
||||
* Largest inline KV value in serialized bytes. Larger values are omitted;
|
||||
* deliveries retain the key metadata.
|
||||
*/
|
||||
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Broadcast deliveries per minute, per subscription.
|
||||
*
|
||||
|
||||
@@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed';
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
@@ -238,7 +233,7 @@ export class EventsWorkerDeployer {
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
return null;
|
||||
return { user } as Actor & {
|
||||
return { user, effectiveApp: null } as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,3 +354,134 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/completeWrite over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('refuses completion of a session whose object never arrived', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
|
||||
size: 4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const { sessionId } = (await started.json()) as { sessionId: string };
|
||||
|
||||
// No PUT to the presigned URL.
|
||||
const response = await fetch(
|
||||
new URL('/fs/completeWrite', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ uploadId: sessionId }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
expect(body.code).toBe('bad_request');
|
||||
expect(body.message).toBe('Upload content was not received');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/startWrite storage quota over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
is_storage_limited: true,
|
||||
storage_capacity: 10 * 1024,
|
||||
} as never);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects the excess of a parallel burst of startWrite calls', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const fs = env.server.services.fs as unknown as {
|
||||
getUsersStorageAllowance: (
|
||||
userId: number,
|
||||
) => Promise<{ curr: number; max: number }>;
|
||||
};
|
||||
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
|
||||
const size = Math.floor((max - curr) / 3) + 1;
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
|
||||
size,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const ok = responses.filter((response) => response.status === 200);
|
||||
const rejected = responses.filter(
|
||||
(response) => response.status === 413,
|
||||
);
|
||||
expect(ok).toHaveLength(2);
|
||||
expect(rejected).toHaveLength(6);
|
||||
|
||||
for (const response of rejected) {
|
||||
const body = (await response.json()) as { code?: string };
|
||||
expect(body.code).toBe('storage_limit_reached');
|
||||
}
|
||||
});
|
||||
|
||||
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
|
||||
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
|
||||
// the wire text is built by hand to exercise what the server's own
|
||||
// `JSON.parse` does with it.
|
||||
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
|
||||
|
||||
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const responseBody = (await response.json()) as { code?: string };
|
||||
expect(responseBody.code).toBe('bad_request');
|
||||
});
|
||||
});
|
||||
@@ -23,9 +23,11 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
@@ -340,9 +342,16 @@ describe('FSController.completeBatchWrites', () => {
|
||||
.body as ClientSignedWriteResponse[];
|
||||
expect(startResponses).toHaveLength(2);
|
||||
|
||||
// 2) Complete via the controller. Single-mode completion only
|
||||
// needs the session row → it doesn't read the S3 object back,
|
||||
// so we can skip the actual upload step in this test.
|
||||
// 2) PUT the declared bytes through each presigned URL, then
|
||||
// complete via the controller.
|
||||
await fetch(startResponses[0]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '12345',
|
||||
});
|
||||
await fetch(startResponses[1]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '1234567',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
const completeBody: CompleteWriteRequest[] = startResponses.map(
|
||||
(r) => ({ uploadId: r.sessionId }),
|
||||
@@ -414,6 +423,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
const firstComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
@@ -545,6 +555,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
@@ -798,6 +809,40 @@ describe('FSController.statEntry', () => {
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws return_shares from the share-listing budget', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
|
||||
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.statEntry(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
// A plain stat spends only its own budget, so it still admits.
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.statEntry(makeReq({ body: { path }, actor }), res),
|
||||
);
|
||||
expect((captured.body as { name: string }).name).toBe('share-budget');
|
||||
});
|
||||
});
|
||||
|
||||
// ── /readdir (readdirEntries) ───────────────────────────────────────
|
||||
@@ -1822,6 +1867,44 @@ describe('FSController.readdirEntries recursive', () => {
|
||||
).toEqual(['l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b']);
|
||||
});
|
||||
|
||||
it('sorts a recursive listing, paging in the same order', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
// A recursive `name` sort is path order, so descending walks the
|
||||
// subtree backwards.
|
||||
const descending = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(
|
||||
descending.items.map((e) => e.path.slice(base.length + 1)),
|
||||
).toEqual(['l1b', 'l1a/l2a/l3a/l4a', 'l1a/l2a/l3a', 'l1a/l2a', 'l1a']);
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
sortOrder: 'desc',
|
||||
limit: 2,
|
||||
cursor,
|
||||
})) as { items: Array<{ path: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((e) => e.path.slice(base.length + 1)));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual([
|
||||
'l1b',
|
||||
'l1a/l2a/l3a/l4a',
|
||||
'l1a/l2a/l3a',
|
||||
'l1a/l2a',
|
||||
'l1a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts the subtree with includeTotal', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
@@ -2657,6 +2740,7 @@ describe('FSController associatedAppId entitlement gate', () => {
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
|
||||
await fetch(started.url!, { method: 'PUT', body: 'abc' });
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
@@ -328,6 +330,9 @@ export class FSController extends PuterController {
|
||||
req.body,
|
||||
);
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
requestBody.uploadId,
|
||||
]);
|
||||
|
||||
const response = await this.services.fs.completeUrlWrite(
|
||||
userId,
|
||||
@@ -373,6 +378,11 @@ export class FSController extends PuterController {
|
||||
for (const requestBody of requests) {
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(
|
||||
req,
|
||||
userId,
|
||||
requests.map((requestBody) => requestBody.uploadId),
|
||||
);
|
||||
const response = await this.services.fs.batchCompleteUrlWrite(
|
||||
userId,
|
||||
requests,
|
||||
@@ -433,6 +443,9 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
@@ -991,6 +1004,17 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const wantsShares = this.#toBoolean(body.return_shares);
|
||||
// `return_shares` is the share-listing route's work, so it spends from
|
||||
// the share-listing budget too, not just `fs:stat`'s.
|
||||
if (
|
||||
wantsShares &&
|
||||
!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))
|
||||
) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
@@ -998,7 +1022,7 @@ export class FSController extends PuterController {
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
this.#toBoolean(body.return_shares)
|
||||
wantsShares
|
||||
? listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -1140,8 +1164,8 @@ export class FSController extends PuterController {
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
includeTotal;
|
||||
|
||||
// Undocumented: `recursive` lists descendants (prefix scan) up to
|
||||
// `depth` levels below the target. Always paginated; sorts by path.
|
||||
// `recursive` lists descendants (prefix scan) up to `depth` levels
|
||||
// below the target. Always paginated.
|
||||
const recursive = this.#toBoolean(body.recursive) === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
@@ -1230,6 +1254,8 @@ export class FSController extends PuterController {
|
||||
? body.cursor
|
||||
: undefined,
|
||||
maxDepth,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
);
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
@@ -2234,8 +2260,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
@@ -2509,6 +2534,40 @@ export class FSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An upload session stays usable for as long as it lives, so the access
|
||||
* `startWrite` checked has to be re-checked against the session's recorded
|
||||
* target every time the caller signs more parts or completes the upload —
|
||||
* otherwise a revoked sharee still lands bytes in the owner's tree.
|
||||
*/
|
||||
async #assertUploadSessionWriteAccess(
|
||||
req: Request,
|
||||
userId: number,
|
||||
uploadIds: Array<string | undefined>,
|
||||
): Promise<void> {
|
||||
// A malformed id is left to the service, which owns that error shape.
|
||||
const knownUploadIds = uploadIds.filter(
|
||||
(uploadId): uploadId is string =>
|
||||
typeof uploadId === 'string' && uploadId.length > 0,
|
||||
);
|
||||
if (knownUploadIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sessions = await this.services.fs.getUploadSessions(
|
||||
userId,
|
||||
knownUploadIds,
|
||||
);
|
||||
await this.#assertBatchWriteAccess(
|
||||
req,
|
||||
sessions.map((session) => ({
|
||||
path: session.targetPath,
|
||||
size: session.size,
|
||||
overwrite: Boolean(session.overwriteTargetUid),
|
||||
})),
|
||||
{ pathAlreadyNormalized: true },
|
||||
);
|
||||
}
|
||||
|
||||
#toEventGuiMetadata(
|
||||
guiMetadata: WriteGuiMetadata | undefined,
|
||||
includeOriginalClientSocketId = true,
|
||||
|
||||
@@ -1024,6 +1024,7 @@ describe('FSController.completeWrite', () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-single.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -1073,6 +1074,7 @@ describe('FSController.completeWrite', () => {
|
||||
`/${username}/Documents/complete-thumb.txt`,
|
||||
2,
|
||||
);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'yz' });
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
@@ -1921,3 +1923,215 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// -- upload sessions outliving their share ----------------------------
|
||||
//
|
||||
// An upload session is authorized once, at `/fs/startWrite`. Resuming or
|
||||
// completing one has to re-check that the caller still has write access to the
|
||||
// session's target, or a revoked sharee lands bytes in the owner's tree.
|
||||
|
||||
describe('FSController upload session access re-checks', () => {
|
||||
const shareWritableFolder = async (folderName: string) => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const folder = `/${owner.username}/Documents/${folderName}`;
|
||||
await withActor(owner.actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path: folder,
|
||||
size: 0,
|
||||
createMissingParents: true,
|
||||
},
|
||||
directory: true,
|
||||
},
|
||||
actor: owner.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(folder);
|
||||
// Read stays granted so the denial is a plain 403 rather than the
|
||||
// existence-masking 404 an invisible path gets.
|
||||
for (const mode of ['read', 'write']) {
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:${mode}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
const revokeWrite = () =>
|
||||
server.services.permission.revokeUserUserPermission(
|
||||
owner.actor,
|
||||
recipient.username,
|
||||
`fs:${entry!.uuid}:write`,
|
||||
);
|
||||
return { owner, recipient, folder, revokeWrite };
|
||||
};
|
||||
|
||||
const startUpload = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
extra: Partial<SignedWriteRequest> = {},
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startWrite(
|
||||
makeReq<SignedWriteRequest>({
|
||||
body: { fileMetadata: { path, size: 8 }, ...extra },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body as ClientSignedWriteResponse;
|
||||
};
|
||||
|
||||
it('refuses to sign more parts once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-parts');
|
||||
const started = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/upload.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: {
|
||||
uploadId: started.sessionId,
|
||||
partNumbers: [1],
|
||||
},
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('refuses to complete a session once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-complete');
|
||||
const target = `${folder}/upload.bin`;
|
||||
const started = await startUpload(recipient.actor, target);
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: started.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses the batch completion once the share is revoked', async () => {
|
||||
const { recipient, folder, revokeWrite } =
|
||||
await shareWritableFolder('revoked-batch');
|
||||
const targets = [`${folder}/batch-a.bin`, `${folder}/batch-b.bin`];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
await revokeWrite();
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('still signs parts and completes while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-share');
|
||||
const multipart = await startUpload(
|
||||
recipient.actor,
|
||||
`${folder}/kept-parts.bin`,
|
||||
{ uploadMode: 'multipart' },
|
||||
);
|
||||
const signed = makeRes();
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: { uploadId: multipart.sessionId, partNumbers: [1] },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
signed.res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(signed.captured.body as { multipartPartUrls: unknown[] })
|
||||
.multipartPartUrls,
|
||||
).toHaveLength(1);
|
||||
|
||||
const target = `${folder}/kept-complete.bin`;
|
||||
const single = await startUpload(recipient.actor, target);
|
||||
await fetch(single.url!, { method: 'PUT', body: '12345678' });
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
body: { uploadId: single.sessionId },
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('completes a batch while the share stands', async () => {
|
||||
const { recipient, folder } = await shareWritableFolder('kept-batch');
|
||||
const targets = [
|
||||
`${folder}/kept-batch-a.bin`,
|
||||
`${folder}/kept-batch-b.bin`,
|
||||
];
|
||||
const started = [
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
for (const s of started) {
|
||||
await fetch(s.url!, { method: 'PUT', body: '12345678' });
|
||||
}
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: started.map((s) => ({ uploadId: s.sessionId })),
|
||||
actor: recipient.actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
for (const target of targets) {
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import type { HttpError } from '../../core/http/HttpError.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
@@ -426,6 +429,40 @@ describe('LegacyFSController.stat', () => {
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws return_shares from the share-listing budget', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/share-budget`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
// Spend the whole share:list bucket, as /share/shares' gate would.
|
||||
const chargeReq = makeReq({ body: {}, actor });
|
||||
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
|
||||
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
|
||||
}
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
|
||||
// A plain stat spends only its own budget, so it still admits.
|
||||
const plain = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(makeReq({ body: { path }, actor }), plain.res),
|
||||
);
|
||||
expect(plain.captured.body).toMatchObject({ name: 'share-budget' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('LegacyFSController.delete', () => {
|
||||
@@ -2312,6 +2349,72 @@ describe('LegacyFSController.checkAppAcl', () => {
|
||||
const body = captured.body as { allowed: boolean };
|
||||
expect(typeof body.allowed).toBe('boolean');
|
||||
});
|
||||
|
||||
it("answers another user's entry the same as a missing one", async () => {
|
||||
const { actor: victim } = await makeUser();
|
||||
const victimPath = `/${victim.user!.username!}/Documents/secret.txt`;
|
||||
await withActor(victim, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: victimPath, set_modified_to_now: true },
|
||||
actor: victim,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const victimEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(victimPath);
|
||||
|
||||
const { actor } = await makeUser();
|
||||
const app = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string }>
|
||||
)(
|
||||
{
|
||||
name: `cacl-${uuidv4()}`,
|
||||
title: 'ACL test app',
|
||||
index_url: 'https://example.test/cacl.html',
|
||||
},
|
||||
{ ownerUserId: actor.user!.id! },
|
||||
);
|
||||
|
||||
const errorFor = async (subject: unknown) => {
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.checkAppAcl(
|
||||
makeReq({
|
||||
body: { subject, app: app.uid, mode: 'read' },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const err = e as HttpError;
|
||||
return {
|
||||
statusCode: err.statusCode,
|
||||
message: err.message,
|
||||
legacyCode: err.legacyCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const missing = await errorFor({
|
||||
path: `/${victim.user!.username!}/Documents/nope.txt`,
|
||||
});
|
||||
expect(missing).toEqual({
|
||||
statusCode: 404,
|
||||
message: 'Subject does not exist',
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
expect(await errorFor({ path: victimPath })).toEqual(missing);
|
||||
expect(await errorFor({ uid: victimEntry!.uuid })).toEqual(missing);
|
||||
expect(await errorFor({ id: victimEntry!.id })).toEqual(missing);
|
||||
expect(await errorFor({ uid: uuidv4() })).toEqual(missing);
|
||||
});
|
||||
});
|
||||
|
||||
// ── down (validation paths) ─────────────────────────────────────────
|
||||
|
||||
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
|
||||
import {
|
||||
assertResolvedActor,
|
||||
isAccessTokenActor,
|
||||
isAppActor,
|
||||
makeActor,
|
||||
} from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -50,6 +51,8 @@ import {
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
@@ -487,6 +490,13 @@ export class LegacyFSController extends PuterController {
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
if (getBoolean(body, 'return_shares')) {
|
||||
// Share-listing work spends the share-listing budget, not just
|
||||
// `fs:stat`'s.
|
||||
if (!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))) {
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
}
|
||||
shaped.shares = await listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
@@ -859,9 +869,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -1291,15 +1299,14 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const isApp = Boolean((actor as { app?: unknown }).app);
|
||||
const actingApp = actor.effectiveApp;
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
|
||||
// Apps can only sign inside their AppData root.
|
||||
let appDataRoot: string | null = null;
|
||||
if (isApp) {
|
||||
const username = (actor as { user?: { username?: string } }).user
|
||||
?.username;
|
||||
const appUid = (actor as { app?: { uid?: string } }).app?.uid;
|
||||
if (actingApp) {
|
||||
const username = actor.user?.username;
|
||||
const appUid = actingApp.uid;
|
||||
if (!username || !appUid)
|
||||
throw new HttpError(403, 'Forbidden', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -1308,8 +1315,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1795,7 +1801,7 @@ export class LegacyFSController extends PuterController {
|
||||
*/
|
||||
openItem = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
if ((actor as { app?: unknown }).app) {
|
||||
if (actor.effectiveApp) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is only available to user sessions',
|
||||
@@ -1889,8 +1895,10 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const actorApp = (actor as { app?: { uid?: string } }).app;
|
||||
if (!actorApp?.uid || actorApp.uid !== appUid) {
|
||||
// The app acting directly, not one that merely issued the credential:
|
||||
// claiming a root dir is the app itself asking, and a token is not it.
|
||||
const callerApp = isAppActor(actor) ? actor.effectiveApp : null;
|
||||
if (callerApp?.uid !== appUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Only the app itself may request its root dir',
|
||||
@@ -1924,25 +1932,40 @@ export class LegacyFSController extends PuterController {
|
||||
* access to a subject FS entry.
|
||||
*/
|
||||
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#requireActor(req);
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const subject = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
subjectRef,
|
||||
);
|
||||
// A missing subject and one the caller can't see get the same 404,
|
||||
// so this can't be used to probe for other users' entries.
|
||||
let subject: import('../../stores/fs/FSEntry.js').FSEntry | null = null;
|
||||
try {
|
||||
subject = await resolveV1Selector(this.stores.fsEntry, subjectRef);
|
||||
} catch (err) {
|
||||
if (!(err instanceof HttpError && err.statusCode === 404))
|
||||
throw err;
|
||||
}
|
||||
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
|
||||
null;
|
||||
const descriptor = subject && {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
|
||||
};
|
||||
const acl = this.services.acl as ACLService;
|
||||
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
let app: { uid: string } | null = null;
|
||||
if (typeof appRef === 'string') {
|
||||
app =
|
||||
@@ -1956,19 +1979,10 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
// Build an actor-under-user shape for the check.
|
||||
const actorForApp = makeActor({
|
||||
user: req.actor!.user,
|
||||
user: actor.user,
|
||||
app: { uid: (app as { uid: string }).uid },
|
||||
});
|
||||
const descriptor = {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(subject.path),
|
||||
};
|
||||
const allowed = await (this.services.acl as ACLService).check(
|
||||
actorForApp,
|
||||
descriptor,
|
||||
mode,
|
||||
);
|
||||
const allowed = await acl.check(actorForApp, descriptor, mode);
|
||||
res.json({ allowed });
|
||||
};
|
||||
|
||||
|
||||
@@ -135,24 +135,11 @@ export interface WriteResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
|
||||
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
|
||||
* references, the storage columns (`bucket`, `bucketRegion`), the owning
|
||||
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
|
||||
* reach the wire. Entries are addressed by `uuid`.
|
||||
*
|
||||
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
|
||||
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
|
||||
* it, so dropping it would break them.
|
||||
*
|
||||
* The `?: never` members below are guards, not fields: they make a raw
|
||||
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
|
||||
* them this type is just a structural subset of `FSEntry`, so an unsanitized
|
||||
* row would be silently assignable and the distinction would buy nothing.
|
||||
*
|
||||
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
|
||||
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
|
||||
* not catch a violation here.
|
||||
* An `FSEntry` safe to send to a client. Built by allowlist so row ids, storage
|
||||
* columns and capability tokens never reach the wire; `shortcutTo` stays
|
||||
* because the v1 contract exposes it. The `?: never` members make a raw
|
||||
* `FSEntry` fail to typecheck where a `ClientFSEntry` is expected; only the
|
||||
* strict `tsconfig.json` catches that, since the build config sets `noCheck`.
|
||||
*/
|
||||
export interface ClientFSEntry {
|
||||
uuid: string;
|
||||
@@ -189,17 +176,12 @@ export interface ClientFSEntry {
|
||||
fileRequestToken?: never;
|
||||
}
|
||||
|
||||
// Wire counterparts of the write responses: what the controller sends after
|
||||
// sanitizing, kept distinct so the compiler can tell the two apart.
|
||||
|
||||
/**
|
||||
* Wire counterparts of the write responses. The bare `…Response` types describe
|
||||
* what the service produces internally (a real `FSEntry`); these describe what
|
||||
* the controller sends after sanitizing. Keeping them distinct is what makes
|
||||
* "did this response get sanitized?" a question the compiler answers.
|
||||
*/
|
||||
/**
|
||||
* The presigned-upload envelope minus the storage internals. A client uploads
|
||||
* to the presigned `url` / `multipartPartUrls`, which already carry everything
|
||||
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
|
||||
* they name where a user's bytes physically live.
|
||||
* The presigned-upload envelope minus storage internals; the presigned URLs
|
||||
* already carry what the store needs.
|
||||
*/
|
||||
export type ClientSignedWriteResponse = Omit<
|
||||
SignedWriteResponse,
|
||||
@@ -221,10 +203,8 @@ export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory-listing entry: a sanitized entry plus the three fields a client
|
||||
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
|
||||
* stored value is an S3 key, which is useless to a client), and the resolved
|
||||
* `associatedApp`.
|
||||
* A sanitized entry plus what a client cannot derive: MIME `type`, a signed
|
||||
* `thumbnail` URL, the resolved `associatedApp`.
|
||||
*/
|
||||
export type ClientReaddirEntry = ClientFSEntry & {
|
||||
type: string | null;
|
||||
|
||||
@@ -40,11 +40,13 @@ import { WebDAVController } from './webdav/WebDAVController.js';
|
||||
import { WispController } from './wisp/WispController.js';
|
||||
import type { IPuterControllerRegistry } from './types.js';
|
||||
import { PeerController } from './peer/PeerController.js';
|
||||
import { ProfileController } from './profile/ProfileController.js';
|
||||
|
||||
export const puterControllers = {
|
||||
staticAssets: StaticAssetsController,
|
||||
staticPages: StaticPagesController,
|
||||
auth: AuthController,
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
|
||||
@@ -58,6 +58,10 @@ let router: PuterRouter;
|
||||
// override in and out (same pattern as AuthController.test.ts).
|
||||
type SignupValidateOverride = (data: Record<string, unknown>) => void;
|
||||
let signupValidateOverride: SignupValidateOverride | null = null;
|
||||
// Stand-in for the extension that honors signup bonus codes.
|
||||
let bonusValidateOverride: SignupValidateOverride | null = null;
|
||||
let bonusCheckOverride: SignupValidateOverride | null = null;
|
||||
const heardSignupSuccess: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
@@ -90,6 +94,24 @@ beforeAll(async () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup-bonus.check',
|
||||
(_k: unknown, data: unknown) => {
|
||||
bonusCheckOverride?.(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup-bonus.validate',
|
||||
(_k: unknown, data: unknown) => {
|
||||
bonusValidateOverride?.(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup.success',
|
||||
(_k: unknown, data: unknown) => {
|
||||
heardSignupSuccess.push(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -99,6 +121,8 @@ afterAll(async () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
signupValidateOverride = null;
|
||||
bonusValidateOverride = null;
|
||||
bonusCheckOverride = null;
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
@@ -955,6 +979,17 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('oidc_login=true');
|
||||
|
||||
// The proof must bind to the account that just completed OIDC so the
|
||||
// popup can refuse a proof replayed in another signed-in browser.
|
||||
const openerState = new URL(captured.redirectUrl!).searchParams.get(
|
||||
'opener_state',
|
||||
);
|
||||
const proof = oidc().verifyPopupReturn(openerState!);
|
||||
expect(proof?.oidc_login).toBe(true);
|
||||
const user = await server.stores.user.getByEmail(email);
|
||||
expect(user?.uuid).toBeTruthy();
|
||||
expect(proof?.user_uuid).toBe(user!.uuid);
|
||||
});
|
||||
|
||||
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
|
||||
@@ -1846,9 +1881,23 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
user_uuid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('hands back the account a proof is bound to', async () => {
|
||||
// The popup compares this against its current user to reject a proof
|
||||
// replayed from another account's login.
|
||||
const proof = server.services.oidc.signPopupReturn({
|
||||
opener_origin: 'https://opener.test',
|
||||
msg_id: '77',
|
||||
oidc_login: true,
|
||||
user_uuid: 'user-A',
|
||||
});
|
||||
const captured = await redeem(proof);
|
||||
expect(captured.body).toMatchObject({ user_uuid: 'user-A' });
|
||||
});
|
||||
|
||||
it('rejects a proof signed with someone else’s key', async () => {
|
||||
// The whole point: only the server can mint one of these.
|
||||
const forged = jwt.sign(
|
||||
@@ -1940,3 +1989,143 @@ describe('OIDCController rate limits', () => {
|
||||
expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Signup bonus codes ----------------------------------------------
|
||||
|
||||
describe('OIDC signup bonus codes', () => {
|
||||
const rand = () => Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const startState = async (query: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({ params: { provider: 'custom' }, query }),
|
||||
res,
|
||||
);
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
return oidc().verifyState(state!) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const stubIdp = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
const openCheck = (data: Record<string, unknown>) => {
|
||||
data.valid = true;
|
||||
data.display = { title: 'T', description: 'D' };
|
||||
};
|
||||
|
||||
const signupCallback = async (bonusCode: string) => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
bonus_code: bonusCode,
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('carries a canonical code in the signed state', async () => {
|
||||
const decoded = await startState({
|
||||
flow: 'signup',
|
||||
bonusCode: 'Startup3M-ABCD1234',
|
||||
});
|
||||
expect(decoded.bonus_code).toBe('startup3mabcd1234');
|
||||
});
|
||||
|
||||
it('drops a malformed code at start', async () => {
|
||||
const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' });
|
||||
expect(decoded).not.toHaveProperty('bonus_code');
|
||||
});
|
||||
|
||||
it('creates the account with the requirements an accepted code raises', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
bonusCheckOverride = openCheck;
|
||||
let seen: Record<string, unknown> | null = null;
|
||||
bonusValidateOverride = (data) => {
|
||||
seen = { ...data };
|
||||
data.accepted = true;
|
||||
data.requires_phone_verification = true;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup3mabcd1234',
|
||||
source: 'oidc',
|
||||
email,
|
||||
});
|
||||
const user = await server.stores.user.findEmailOwner(email, {
|
||||
force: true,
|
||||
});
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(true);
|
||||
const deadline = Date.now() + 2000;
|
||||
let success: Record<string, unknown> | undefined;
|
||||
while (!success && Date.now() < deadline) {
|
||||
success = heardSignupSuccess.find((e) => e.email === email);
|
||||
if (!success) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(success?.bonus_code).toBe('startup3mabcd1234');
|
||||
});
|
||||
|
||||
it('refuses a dead code before the validate hook runs', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
let validated = false;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email === email) validated = true;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
|
||||
expect(validated).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses the account when nothing accepts the code, without echoing it', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
|
||||
expect(captured.redirectUrl).not.toContain('bonusCode=');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
expect(
|
||||
await server.stores.user.findEmailOwner(email, { force: true }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps the code on the retry redirect when something else failed', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
bonusCheckOverride = openCheck;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email === email) data.allow = false;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
|
||||
import {
|
||||
SHARE_DEEP_LINK_ITEMS_LIMIT,
|
||||
@@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [
|
||||
'account_suspended',
|
||||
'unauthorized',
|
||||
'signup_blocked',
|
||||
'bonus_code_invalid',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string {
|
||||
: 'signup_blocked';
|
||||
}
|
||||
|
||||
/** What a new account created by an OIDC callback inherits from its flow. */
|
||||
interface OIDCSignupOptions {
|
||||
referrer?: string | null;
|
||||
bonusCode?: string | null;
|
||||
}
|
||||
|
||||
function signupOptionsFromState(
|
||||
stateDecoded: Record<string, unknown>,
|
||||
): OIDCSignupOptions {
|
||||
return {
|
||||
referrer: (stateDecoded.referrer as string) ?? null,
|
||||
// Normalized when the state was signed; re-checked since this is the
|
||||
// value signup acts on.
|
||||
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
|
||||
};
|
||||
}
|
||||
|
||||
// GUI pages an OIDC flow may return to: the root (where a share email lands),
|
||||
// /desktop, /dashboard, and direct app landings (/app/<name> and its
|
||||
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
|
||||
@@ -222,6 +241,13 @@ function buildErrorRedirectUrl(
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
// A retry from the error page keeps the bonus code, unless it's what failed.
|
||||
if (
|
||||
typeof stateDecoded?.bonus_code === 'string' &&
|
||||
clamped !== 'bonus_code_invalid'
|
||||
) {
|
||||
params.set('bonusCode', stateDecoded.bonus_code);
|
||||
}
|
||||
for (const path of sharedPaths) {
|
||||
params.append(SHARE_DEEP_LINK_PARAM, path);
|
||||
}
|
||||
@@ -302,6 +328,7 @@ export class OIDCController extends PuterController {
|
||||
opener_origin: decoded.opener_origin ?? null,
|
||||
msg_id: decoded.msg_id ?? null,
|
||||
oidc_login: decoded.oidc_login === true,
|
||||
user_uuid: decoded.user_uuid ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -422,6 +449,15 @@ export class OIDCController extends PuterController {
|
||||
redirect_uri: appRedirectUri,
|
||||
};
|
||||
if (referrer) statePayload.referrer = referrer ?? openerOrigin;
|
||||
// Login can create an account too, so both flows carry it. A
|
||||
// malformed code is dropped here and never reaches signup.
|
||||
const rawBonusCode = Array.isArray(req.query.bonusCode)
|
||||
? req.query.bonusCode[0]
|
||||
: req.query.bonusCode;
|
||||
const bonusCode = normalizeBonusCode(rawBonusCode);
|
||||
if (bonusCode && (flow === 'login' || flow === 'signup')) {
|
||||
statePayload.bonus_code = bonusCode;
|
||||
}
|
||||
if (embeddedInPopup && msgId) {
|
||||
statePayload.embedded_in_popup = true;
|
||||
statePayload.msg_id = msgId;
|
||||
@@ -506,7 +542,7 @@ export class OIDCController extends PuterController {
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
signupOptionsFromState(stateDecoded),
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
@@ -573,7 +609,7 @@ export class OIDCController extends PuterController {
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
signupOptionsFromState(stateDecoded),
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
@@ -741,7 +777,7 @@ if (window.opener) {
|
||||
async #resolveOrCreateOIDCUser(
|
||||
provider: string,
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
signup: OIDCSignupOptions = {},
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
@@ -791,7 +827,8 @@ if (window.opener) {
|
||||
const outcome = await this.services.oidc.createUserFromOIDC(
|
||||
provider,
|
||||
userinfo as { sub: string; email?: string },
|
||||
referrer,
|
||||
signup.referrer ?? null,
|
||||
{ bonusCode: signup.bonusCode ?? null },
|
||||
);
|
||||
// A concurrent callback (a second tab, a provider retry) created the
|
||||
// account between step 2 and the insert. Nothing went wrong for the
|
||||
@@ -801,7 +838,7 @@ if (window.opener) {
|
||||
return this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
referrer,
|
||||
signup,
|
||||
attempt + 1,
|
||||
);
|
||||
}
|
||||
@@ -924,6 +961,11 @@ if (window.opener) {
|
||||
// identity to mint a token for, so it needs the integrity this
|
||||
// state already carries — re-signed here, at the one point where
|
||||
// the round trip is known to have actually happened.
|
||||
//
|
||||
// `user_uuid` binds the proof to the account that just completed
|
||||
// OIDC. Without it a proof from one login could be replayed in
|
||||
// another signed-in browser to skip its account picker; the popup
|
||||
// only honors `oidc_login` when this matches its current user.
|
||||
target = appendQueryParam(
|
||||
target,
|
||||
'opener_state',
|
||||
@@ -931,6 +973,7 @@ if (window.opener) {
|
||||
opener_origin: stateDecoded.opener_origin ?? null,
|
||||
msg_id: stateDecoded.msg_id ?? null,
|
||||
oidc_login: true,
|
||||
user_uuid: user.uuid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -382,10 +383,10 @@ describe('PeerController TURN', () => {
|
||||
json: async () => ({ iceServers: [] }),
|
||||
} as never);
|
||||
try {
|
||||
const appActor = {
|
||||
const appActor = makeActor({
|
||||
...userActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
};
|
||||
});
|
||||
await generateTurn(makeReq({ actor: appActor }), makeRes().res);
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const { customIdentifier } = JSON.parse(init.body as string) as {
|
||||
@@ -667,10 +668,12 @@ describe('PeerController guest TURN', () => {
|
||||
});
|
||||
|
||||
it('carries the app segment for an app-under-user host', () => {
|
||||
const grant = mintGrant({
|
||||
...hostActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
});
|
||||
const grant = mintGrant(
|
||||
makeActor({
|
||||
...hostActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
}),
|
||||
);
|
||||
|
||||
const verified = verifyGuestGrant({
|
||||
grant,
|
||||
|
||||
@@ -98,8 +98,9 @@ const base64urlToUuid = (encoded: string): string | null => {
|
||||
*/
|
||||
const actorToTurnIdentifier = (actor: Actor): string => {
|
||||
const userPart = uuidToBase64url(actor.user.uuid);
|
||||
if (!actor.app) return userPart;
|
||||
return `${userPart}:${uuidToBase64url(actor.app.uid)}`;
|
||||
const appUid = actor.effectiveApp?.uid;
|
||||
if (!appUid) return userPart;
|
||||
return `${userPart}:${uuidToBase64url(appUid)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { toMicroCents } from '../../services/metering/utils';
|
||||
|
||||
// Microcents per byte of TURN egress ($0.05/GB).
|
||||
export const PEER_COSTS = {
|
||||
'turn:egress-bytes': 0.005,
|
||||
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
|
||||
} as const;
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const PICTURE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=';
|
||||
|
||||
describe('profile routes over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let userUuid = '';
|
||||
/** The plan `env.users.user` is on; see the resolver below. */
|
||||
let plan: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
userUuid = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.uuid;
|
||||
env.server.services.metering.registerPolicy({
|
||||
id: 'business',
|
||||
monthUsageAllowance: 45 * 1_000_000 * 100,
|
||||
monthlyStorageAllowance: 1024 ** 3,
|
||||
});
|
||||
env.server.services.metering.registerSubscriptionResolver((actor) =>
|
||||
actor.user?.uuid === userUuid ? plan : null,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const withSubscription = async <T>(
|
||||
id: string | null,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
plan = id;
|
||||
env.server.services.metering.invalidateActorSubscription(userUuid);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
plan = null;
|
||||
env.server.services.metering.invalidateActorSubscription(userUuid);
|
||||
}
|
||||
};
|
||||
|
||||
const get = (query: string, token?: string) =>
|
||||
fetch(new URL(`/profile${query}`, env.apiOrigin), {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
const post = (body: unknown, token: string) =>
|
||||
fetch(new URL('/profile', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('lets a user write and read their own profile on any plan', async () => {
|
||||
const write = await post(
|
||||
{ picture: PICTURE, displayName: 'Test' },
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(write.status).toBe(200);
|
||||
expect(await write.json()).toEqual({
|
||||
picture: PICTURE,
|
||||
displayName: 'Test',
|
||||
bio: null,
|
||||
});
|
||||
|
||||
const own = await get('', env.users.user.token);
|
||||
expect(own.status).toBe(200);
|
||||
expect(await own.json()).toMatchObject({ picture: PICTURE });
|
||||
|
||||
const byName = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(byName.status).toBe(200);
|
||||
expect(await byName.json()).toMatchObject({ displayName: 'Test' });
|
||||
});
|
||||
|
||||
it('hides a free user from anonymous and third-party readers as a 404', async () => {
|
||||
const anonymous = await get(`?username=${env.users.user.username}`);
|
||||
expect(anonymous.status).toBe(404);
|
||||
const other = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(other.status).toBe(404);
|
||||
// Same shape as an account that does not exist.
|
||||
const missing = await get('?username=nobody-here');
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
it('serves a paid user to anyone, and hides them again when the plan ends', async () => {
|
||||
await withSubscription('business', async () => {
|
||||
const anonymous = await get(`?username=${env.users.user.username}`);
|
||||
expect(anonymous.status).toBe(200);
|
||||
expect(await anonymous.json()).toMatchObject({ picture: PICTURE });
|
||||
const other = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(other.status).toBe(200);
|
||||
});
|
||||
expect((await get(`?username=${env.users.user.username}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a username or a signed-in caller, and validates the username shape', async () => {
|
||||
expect((await get('')).status).toBe(401);
|
||||
expect((await get('?username=not%20valid')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a bad patch with a stable code and refuses app and anonymous writers', async () => {
|
||||
const bad = await post({ name: 'x' }, env.users.user.token);
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toMatchObject({
|
||||
code: 'profile_field_not_allowed',
|
||||
});
|
||||
|
||||
const anonymous = await fetch(new URL('/profile', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ bio: 'x' }),
|
||||
});
|
||||
expect(anonymous.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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, Response } from 'express';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { ProfileService } from '../../services/profile/ProfileService.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
// Shape only; the store decides whether the account exists.
|
||||
const USERNAME_PATTERN = /^[a-z0-9_-]{1,64}$/i;
|
||||
|
||||
const notFound = () =>
|
||||
new HttpError(404, 'Profile not found', { legacyCode: 'not_found' });
|
||||
|
||||
/**
|
||||
* User profiles. Reads are open so an app can show another user's profile, but
|
||||
* a profile other than the caller's own is served only while its owner is on a
|
||||
* paid plan (`ProfileService.isPubliclyVisible`); a hidden one 404s like a
|
||||
* missing account, so the response says nothing about the plan.
|
||||
*/
|
||||
@Controller('/profile')
|
||||
export class ProfileController extends PuterController {
|
||||
/**
|
||||
* GET /profile?username=<name> — a user's profile. Without `username`, the
|
||||
* signed-in user's own.
|
||||
*/
|
||||
@Get('/', {
|
||||
subdomain: 'api',
|
||||
rateLimit: {
|
||||
scope: 'profile-read',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
})
|
||||
async get(req: Request, res: Response): Promise<void> {
|
||||
const service = this.services.profile as ProfileService;
|
||||
const caller = req.actor?.user;
|
||||
const username = req.query.username;
|
||||
|
||||
let target;
|
||||
if (username === undefined) {
|
||||
if (!caller?.uuid) {
|
||||
throw new HttpError(401, 'Sign in, or pass a username', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUuid(caller.uuid);
|
||||
} else {
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
!USERNAME_PATTERN.test(username)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid username', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUsername(username);
|
||||
}
|
||||
if (!target) throw notFound();
|
||||
|
||||
const isOwner = caller?.uuid === target.uuid;
|
||||
if (!isOwner && !(await service.isPubliclyVisible(target))) {
|
||||
throw notFound();
|
||||
}
|
||||
res.json(await service.getProfile(target));
|
||||
}
|
||||
|
||||
/** POST /profile — update the signed-in user's own profile. */
|
||||
@Post('/', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'profile-write',
|
||||
limit: 30,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
const service = this.services.profile as ProfileService;
|
||||
const user = await this.stores.user.getByUuid(req.actor!.user.uuid!);
|
||||
if (!user) throw notFound();
|
||||
res.json(await service.updateProfile(user, req.body));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { ShareController } from './ShareController.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
|
||||
/**
|
||||
* Route-level coverage for the sharing endpoints. The service unit tests drive
|
||||
@@ -1069,4 +1070,156 @@ describe('share endpoints over HTTP', () => {
|
||||
expect(route.options?.allowFullAccessToken).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
describe('an invited address over the wire', () => {
|
||||
/** An app of the owner's, holding `mode` on one file, with a token. */
|
||||
const appHolding = async (
|
||||
owner: { username: string },
|
||||
uid: string,
|
||||
permission: (fileUid: string) => string,
|
||||
) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `peek-app-${crypto.randomUUID().slice(0, 8)}`,
|
||||
title: 'Peek app',
|
||||
index_url: `https://peek-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: user!.id },
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
env.server.services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
permission(uid),
|
||||
),
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
return { ...app, token };
|
||||
};
|
||||
|
||||
const invitedFile = async (owner: { username: string; token: string }) => {
|
||||
const file = await makeFile(owner);
|
||||
const email = `wire-${crypto.randomUUID().slice(0, 8)}@test.local`;
|
||||
const res = await post('/share', owner.token, {
|
||||
recipients: [email],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return { file, email };
|
||||
};
|
||||
|
||||
it('reaches the owner but not an app the file was handed to', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
|
||||
// The owner asked, so the owner is told.
|
||||
const mine = await get('/share/shares', owner.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(mine.status).toBe(200);
|
||||
expect(await mine.text()).toContain(email);
|
||||
|
||||
// The app is answered, but bounded to the rows it issued — and it
|
||||
// issued none, so the owner's invite is not among them.
|
||||
const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`);
|
||||
const peeked = await get('/share/shares', app.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(peeked.status).toBe(200);
|
||||
const peekedBody = await peeked.text();
|
||||
expect(peekedBody).not.toContain(email);
|
||||
expect(JSON.parse(peekedBody).items).toEqual([]);
|
||||
|
||||
// `return_shares` is the same listing on an access-token surface.
|
||||
const stat = await post('/fs/stat', app.token, {
|
||||
uid: file.uid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.status).toBe(200);
|
||||
const body = await stat.text();
|
||||
expect(body).not.toContain(email);
|
||||
expect(JSON.parse(body).shares).toEqual([]);
|
||||
});
|
||||
|
||||
// The ticket's second route in: `ShareController` admits no access
|
||||
// token, but `/fs/stat` does, and `return_shares` is the same listing.
|
||||
it('withholds the listing from a list-scoped access token', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const scoped = await runWithContext({ actor }, () =>
|
||||
env.server.services.auth.createAccessToken(actor, [
|
||||
[`fs:${file.uid}:list`],
|
||||
]),
|
||||
);
|
||||
|
||||
const stat = await post('/fs/stat', scoped, {
|
||||
uid: file.uid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.status).toBe(200);
|
||||
const body = await stat.text();
|
||||
expect(body).not.toContain(email);
|
||||
expect(JSON.parse(body).shares).toEqual([]);
|
||||
});
|
||||
|
||||
// Withholding here would buy nothing: `shared-by-me` hands the same
|
||||
// addresses to the same credential.
|
||||
it('tells a full-access token what it could read from shared-by-me anyway', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const pat = await runWithContext({ actor }, () =>
|
||||
env.server.services.auth.createAccessToken(actor, [
|
||||
[FULL_API_ACCESS],
|
||||
]),
|
||||
);
|
||||
|
||||
const outbound = await get('/share/shared-by-me', pat, {});
|
||||
expect(outbound.status).toBe(200);
|
||||
expect(await outbound.text()).toContain(email);
|
||||
|
||||
const listed = await get('/share/shares', pat, { uid: file.uid });
|
||||
expect(listed.status).toBe(200);
|
||||
expect(await listed.text()).toContain(email);
|
||||
});
|
||||
|
||||
it('answers an app that was handed the item to manage', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const app = await appHolding(
|
||||
owner,
|
||||
file.uid,
|
||||
(u) => `manage:fs:${u}`,
|
||||
);
|
||||
|
||||
const res = await get('/share/shares', app.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
// `manage` buys an app authority over the item, not sight of who
|
||||
// else the owner invited to it.
|
||||
expect(
|
||||
JSON.parse(text).items.some(
|
||||
(i: { pending?: boolean }) => i.pending,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(text).not.toContain(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,28 +31,7 @@ import { toClientShare } from './clientShare.js';
|
||||
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/**
|
||||
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
|
||||
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
|
||||
* what `ShareService`'s per-day quota is for.
|
||||
*/
|
||||
const SHARE_LIMIT = [
|
||||
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' as const },
|
||||
{
|
||||
scope: 'share:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const SHARE_LIST_LIMIT = {
|
||||
scope: 'share:list',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
import { SHARE_LIMIT, SHARE_LIST_LIMIT } from './limits.js';
|
||||
|
||||
/** Distinct (holder, item) pairs run together; see the note on grouping below. */
|
||||
const SHARE_CONCURRENCY = 8;
|
||||
@@ -104,10 +83,15 @@ export class ShareController extends PuterController {
|
||||
* POST /share — grant `mode` on one or more items to one or more
|
||||
* recipients. Partial success is the contract: each pair reports its own
|
||||
* outcome and the envelope summarizes.
|
||||
*
|
||||
* Handing out access is the one share surface that reaches other people, so
|
||||
* it asks for a verified phone or card first; withdrawing and listing never
|
||||
* do — a caller must always be able to see and undo what it shared.
|
||||
*/
|
||||
@Post('', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
requireAnyVerified: ['phone', 'card'],
|
||||
rateLimit: SHARE_LIMIT,
|
||||
})
|
||||
async createShares(req: Request, res: Response): Promise<void> {
|
||||
@@ -644,6 +628,7 @@ export class ShareController extends PuterController {
|
||||
|
||||
/** Echoes back the identifier the caller named, so results are matchable. */
|
||||
#recipientLabel(recipient: ShareRecipient): string {
|
||||
if (recipient.anyone) return 'anyone';
|
||||
return (
|
||||
recipient.email ??
|
||||
recipient.username ??
|
||||
@@ -670,6 +655,12 @@ export class ShareController extends PuterController {
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const rec = entry as Record<string, unknown>;
|
||||
// Object form only, and the literal `true`: nothing typed into
|
||||
// a people field can become "everyone".
|
||||
if (rec.anyone === true) {
|
||||
out.push({ anyone: true });
|
||||
continue;
|
||||
}
|
||||
const email =
|
||||
typeof rec.email === 'string' ? rec.email.trim() : '';
|
||||
const username =
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
|
||||
|
||||
/**
|
||||
* Handing out a share asks for a verified phone or card. Its own env, with an
|
||||
* SMS provider configured: the sibling share tests run without one, where the
|
||||
* gate is inert by design.
|
||||
*/
|
||||
describe('share verification gate over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
prelude: { apiKey: 'test-key' },
|
||||
} as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const post = (path: string, token: string, body: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const get = (path: string, token: string) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/** A fresh account, optionally with a verification artifact on its row. */
|
||||
const makeUser = async (patch: Record<string, unknown> = {}) => {
|
||||
const username = `svg${Math.random().toString(36).slice(2, 9)}`;
|
||||
const creds = await createTestUser(env.server, {
|
||||
username,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const row = await env.server.stores.user.getByUsername(username);
|
||||
await env.server.stores.user.update(row!.id, patch);
|
||||
}
|
||||
return creds;
|
||||
};
|
||||
|
||||
const makeFile = async (owner: { username: string }) => {
|
||||
const uid = crypto.randomUUID();
|
||||
const name = `share-gate-${uid.slice(0, 8)}.txt`;
|
||||
const path = `/${owner.username}/${name}`;
|
||||
const user = await env.server.stores.user.getByUsername(owner.username);
|
||||
await env.server.clients.db.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
|
||||
[uid, name, path, user!.id, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
return { uid, path, name };
|
||||
};
|
||||
|
||||
const share = (owner: TestUserCredentials, uid: string) =>
|
||||
post('/share', owner.token, {
|
||||
recipients: [env.users.other.username],
|
||||
items: [{ uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
it('turns an unverified owner away, leading with the phone flow', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner);
|
||||
const res = await share(owner, file.uid);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'phone_verification_required',
|
||||
factors: ['phone'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an owner with a verified number share', async () => {
|
||||
const owner = await makeUser({ phone: '+14155550123' });
|
||||
const file = await makeFile(owner);
|
||||
const res = await share(owner, file.uid);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ status: 'success' });
|
||||
});
|
||||
|
||||
it('lets a paying owner share without either factor', async () => {
|
||||
const owner = await makeUser();
|
||||
const row = await env.server.stores.user.getByUsername(owner.username);
|
||||
const metering = env.server.services.metering;
|
||||
const real = metering.getActorSubscription.bind(metering);
|
||||
const spy = vi
|
||||
.spyOn(metering, 'getActorSubscription')
|
||||
.mockImplementation(async (actor) =>
|
||||
actor.user?.uuid === row!.uuid
|
||||
? ({ id: 'business' } as never)
|
||||
: real(actor),
|
||||
);
|
||||
try {
|
||||
const file = await makeFile(owner);
|
||||
expect((await share(owner, file.uid)).status).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
metering.invalidateActorSubscription(row!.uuid);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a verified card once an extension reports the card gate on', async () => {
|
||||
const cardGateOn = (
|
||||
_key: string,
|
||||
data: { enabled: boolean | null },
|
||||
) => {
|
||||
data.enabled = true;
|
||||
};
|
||||
resetCardVerificationStatusCache();
|
||||
env.server.clients.event.on(
|
||||
'puter.card-verification.status',
|
||||
cardGateOn,
|
||||
);
|
||||
try {
|
||||
const carded = await makeUser({ card_fingerprint: 'fp_test' });
|
||||
const file = await makeFile(carded);
|
||||
expect((await share(carded, file.uid)).status).toBe(200);
|
||||
|
||||
// ...and an owner with neither is now offered both.
|
||||
const bare = await makeUser();
|
||||
const bareFile = await makeFile(bare);
|
||||
const res = await share(bare, bareFile.uid);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
code: 'phone_verification_required',
|
||||
factors: ['phone', 'card'],
|
||||
});
|
||||
} finally {
|
||||
env.server.clients.event.off(
|
||||
'puter.card-verification.status',
|
||||
cardGateOn,
|
||||
);
|
||||
resetCardVerificationStatusCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('never gates withdrawing or listing shares', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner);
|
||||
const revoke = await post('/share/revoke', owner.token, {
|
||||
recipients: [env.users.other.username],
|
||||
items: [{ uid: file.uid }],
|
||||
});
|
||||
expect(revoke.status).toBe(200);
|
||||
const list = await get('/share/shared-by-me', owner.token);
|
||||
expect(list.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -54,9 +54,17 @@ export async function toClientShare(
|
||||
...(share.type === undefined ? {} : { type: share.type }),
|
||||
...(thumbnail === undefined ? {} : { thumbnail }),
|
||||
...(share.owner === undefined ? {} : { owner: share.owner.username }),
|
||||
// Withheld from a listing whose caller it isn't for, hence the omission.
|
||||
...(share.pending
|
||||
? { pending: true, recipient_email: share.recipientEmail }
|
||||
? {
|
||||
pending: true,
|
||||
...(share.recipientEmail === undefined
|
||||
? {}
|
||||
: { recipient_email: share.recipientEmail }),
|
||||
}
|
||||
: {}),
|
||||
// A link share: no holder of any kind, this is what says so.
|
||||
...(share.anyone ? { anyone: true } : {}),
|
||||
// Set on a share call only, so a listing stays silent about it.
|
||||
...(share.isNew === undefined ? {} : { is_new: share.isNew }),
|
||||
uid_entry: share.entryUid,
|
||||
|
||||
@@ -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/>.
|
||||
*/
|
||||
|
||||
import type { RouteRateLimit } from '../../core/http/types';
|
||||
|
||||
// Shared like `fs/limits.ts` is shared: the scope only ties counters
|
||||
// together if every caller passes the same spec.
|
||||
|
||||
/**
|
||||
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
|
||||
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
|
||||
* what `ShareService`'s per-day quota is for.
|
||||
*/
|
||||
export const SHARE_LIMIT: RouteRateLimit[] = [
|
||||
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' },
|
||||
{
|
||||
scope: 'share:mutate-daily',
|
||||
limit: 500,
|
||||
window: 24 * 60 * 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* One bucket for every share-listing read. The `/share` routes gate on it, and
|
||||
* `stat` charges it imperatively when `return_shares` asks for the same listing
|
||||
* work — otherwise the flag runs share listings under `fs:stat`'s far more
|
||||
* generous budget.
|
||||
*/
|
||||
export const SHARE_LIST_LIMIT: RouteRateLimit = {
|
||||
scope: 'share:list',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
@@ -53,6 +54,47 @@ describe('team endpoints over HTTP', () => {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** An app token acting for `owner`, minted the way the dashboard does. */
|
||||
const makeAppToken = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `team-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Team app',
|
||||
index_url: `https://team-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor, app.uid);
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
|
||||
await env.server.services.fs.mkdir(user!.id, {
|
||||
path: anchor,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'teams-scope' },
|
||||
);
|
||||
};
|
||||
|
||||
/** A team owned by `user`, with one provisioned member. */
|
||||
const makeTeam = async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
@@ -199,6 +241,15 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as { items: { username: string }[] };
|
||||
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
|
||||
|
||||
// A scoped token was minted for one thing; a roster is not it.
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
const narrow = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
typeof scoped === 'string' ? scoped : scoped.token,
|
||||
);
|
||||
expect(narrow.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses the directory to someone outside the team', async () => {
|
||||
@@ -215,6 +266,188 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- app actors, gated by the same opt-in as the directory --------
|
||||
//
|
||||
// Built through the service, not the wire: these routes share a
|
||||
// rate-limit budget with the mutation tests above, and every HTTP
|
||||
// create/update here would eat into that same window.
|
||||
|
||||
/** A team owned by `user`, created and toggled through the service. */
|
||||
const makeServiceTeam = async () => {
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
return { team, ownerId: owner.id };
|
||||
};
|
||||
|
||||
it('omits a team from an app token until the directory opts in', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call('GET', '/teams', appToken);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = (await before.json()) as { items: { uid: string }[] };
|
||||
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call('GET', '/teams', appToken);
|
||||
expect(after.status).toBe(200);
|
||||
const afterBody = (await after.json()) as { items: { uid: string }[] };
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a team to a full-access API token with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.apiToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
});
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(before.status).toBe(404);
|
||||
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
// An app gets username only — no org_owned, created_at, or uuid.
|
||||
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('still refuses an app token on GET /teams/:uid', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}`, appToken);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username: neverActivated,
|
||||
});
|
||||
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const suspended = await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
ownerId,
|
||||
{ username: suspendedUsername },
|
||||
);
|
||||
await env.server.services.team.disableMember(
|
||||
team.uid,
|
||||
ownerId,
|
||||
suspended.userId,
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
const appView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(appView.status).toBe(200);
|
||||
const appItems = (
|
||||
(await appView.json()) as { items: Record<string, unknown>[] }
|
||||
).items;
|
||||
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
|
||||
const appUsernames = appItems.map((m) => m.username);
|
||||
expect(appUsernames).not.toContain(neverActivated);
|
||||
expect(appUsernames).not.toContain(suspendedUsername);
|
||||
// The owner's own seat is active, so it still shows.
|
||||
expect(appUsernames).toContain(env.users.user.username);
|
||||
|
||||
// The owner's own session sees every seat regardless of status.
|
||||
const ownerView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(ownerView.status).toBe(200);
|
||||
const ownerUsernames = (
|
||||
(await ownerView.json()) as { items: { username: string }[] }
|
||||
).items.map((m) => m.username);
|
||||
expect(ownerUsernames).toContain(neverActivated);
|
||||
expect(ownerUsernames).toContain(suspendedUsername);
|
||||
});
|
||||
|
||||
it('gives a full-access API token the owner`s uuid on /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.apiToken,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; uuid?: string }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
expect(owner?.uuid).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('refuses a scoped access token on GET /teams and /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
|
||||
const teamsRes = await call('GET', '/teams', scoped);
|
||||
expect(teamsRes.status).toBe(403);
|
||||
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
|
||||
const membersRes = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
scoped,
|
||||
);
|
||||
expect(membersRes.status).toBe(403);
|
||||
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('lists members with org_owned distinguishing the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
|
||||
@@ -235,6 +468,52 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(member?.org_owned).toBe(true);
|
||||
});
|
||||
|
||||
it('gives seat uuids to the owner and to nobody else', async () => {
|
||||
// Built through the service, not the wire: provisioning shares a
|
||||
// rate-limit budget with the seat tests below, and one more HTTP
|
||||
// provision here 429s them.
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const slug0 = Math.random().toString(36).slice(2, 9);
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: `uuidt-${slug0}`,
|
||||
});
|
||||
const memberUsername = `seat_${slug0}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, owner.id, {
|
||||
username: memberUsername,
|
||||
});
|
||||
|
||||
const owned = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
|
||||
).json()) as { items: { username: string; uuid?: string }[] };
|
||||
const seat = owned.items.find((m) => m.username === memberUsername);
|
||||
// Billing keys a seat's plan on this, so the owner cannot act without it.
|
||||
expect(seat?.uuid).toEqual(expect.any(String));
|
||||
|
||||
// A throwaway member, not the shared fixture: joining a team is
|
||||
// permanent and would follow `other` into every later test.
|
||||
const slug = Math.random().toString(36).slice(2, 9);
|
||||
const joiner = await env.server.stores.user.create({
|
||||
username: `joiner_${slug}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
password: 'hashed',
|
||||
email: `joiner_${slug}@test.local`,
|
||||
});
|
||||
await env.server.stores.team.addMember(team.uid, joiner.id, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const { token } =
|
||||
await env.server.services.auth.createSessionToken(joiner);
|
||||
|
||||
const seen = (await (
|
||||
await call('GET', `/teams/${team.uid}/members`, token)
|
||||
).json()) as { items: { uuid?: string }[] };
|
||||
expect(seen.items.length).toBeGreaterThan(0);
|
||||
for (const m of seen.items) expect(m.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- provisioning over the wire -----------------------------------
|
||||
|
||||
it('never returns the activation link to the administrator', async () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { TeamRow } from '../../stores/team/TeamStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -70,7 +71,9 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
|
||||
|
||||
@Controller('/teams')
|
||||
export class TeamController extends PuterController {
|
||||
// `requireUserActor` installs the auth gates; reads need it too.
|
||||
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
|
||||
// use `requireAuth` so an app can read a team whose owner opted into the
|
||||
// directory (see `directory_enabled`).
|
||||
|
||||
/** Off means `/teams` 404s and no team route is registered at all. */
|
||||
isEnabled(): boolean {
|
||||
@@ -85,6 +88,7 @@ export class TeamController extends PuterController {
|
||||
})
|
||||
async createTeam(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const body = this.#body(req);
|
||||
|
||||
const team = await this.services.team.createTeam(userId, {
|
||||
@@ -99,15 +103,21 @@ export class TeamController extends PuterController {
|
||||
|
||||
@Get('', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listTeams(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const teams = await this.stores.team.listTeamsForUser(userId);
|
||||
// An app only sees a team whose owner opened the directory to apps;
|
||||
// a team that hasn't is indistinguishable from no team at all.
|
||||
const visible = isAccountContext(req.actor)
|
||||
? teams
|
||||
: teams.filter((t) => this.services.team.isDirectoryOpen(t));
|
||||
res.json({
|
||||
items: teams.map((t) =>
|
||||
items: visible.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
});
|
||||
@@ -176,16 +186,23 @@ export class TeamController extends PuterController {
|
||||
|
||||
@Get('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
async listMembers(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.services.team.requireMembership(
|
||||
const team = await this.services.team.requireMembership(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
const accountContext = isAccountContext(req.actor);
|
||||
// Same consent as the directory: an app only reads a team that opted in.
|
||||
if (!accountContext) this.services.team.assertDirectoryOpen(team);
|
||||
|
||||
// uuid only when the team owner calls with their own session or API
|
||||
// token; an app needing a stable id uses `listDirectory()` instead.
|
||||
const showUuid = accountContext && team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
@@ -195,22 +212,30 @@ export class TeamController extends PuterController {
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
// An app's view matches the directory's: active seats only.
|
||||
activeOnly: !accountContext,
|
||||
},
|
||||
);
|
||||
// An app only ever gets a username; org_owned/created_at are account-only.
|
||||
res.json({
|
||||
items: page.items.map((m) => ({
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
})),
|
||||
items: page.items.map((m) =>
|
||||
accountContext
|
||||
? {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(showUuid ? { uuid: m.uuid } : {}),
|
||||
}
|
||||
: { username: m.username },
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The only team route that admits an app actor. It discloses nothing a
|
||||
* colleague cannot already read through `/members`, and the team has to
|
||||
* have opted in, so an app cannot enumerate a team by default.
|
||||
* Admits an app actor, like `listTeams` and `listMembers`, once the team
|
||||
* has opted in. Discloses nothing a colleague cannot already read through
|
||||
* `/members`.
|
||||
*/
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
@@ -220,6 +245,15 @@ export class TeamController extends PuterController {
|
||||
async listDirectory(req: Request, res: Response): Promise<void> {
|
||||
// The membership is the person's, never the app's.
|
||||
const userId = this.#requireUserId(req);
|
||||
// A scoped token holds only what it was minted for; a roster is not that.
|
||||
const token = req.actor?.accessToken;
|
||||
if (token && token.fullAccess !== true) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is not available to scoped access tokens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
const page = await this.services.team.listDirectory(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
@@ -249,7 +283,7 @@ export class TeamController extends PuterController {
|
||||
const body = this.#body(req);
|
||||
const result = await this.services.team.provisionAccount(uid, userId, {
|
||||
username: this.#requireString(body.username, 'username'),
|
||||
email: this.#requireString(body.email, 'email'),
|
||||
email: this.#optionalString(body.email, 'email'),
|
||||
});
|
||||
// Shown once; the admin delivers it out of band.
|
||||
res.json({
|
||||
@@ -349,8 +383,8 @@ export class TeamController extends PuterController {
|
||||
async deleteMember(req: Request, res: Response): Promise<void> {
|
||||
const userId = this.#requireUserId(req);
|
||||
const uid = this.#param(req, 'uid');
|
||||
// Authority first, or resolving `:username` is an existence oracle.
|
||||
await this.services.team.requireOwner(uid, userId);
|
||||
// Authority first (anti-oracle); deleted team included, see the service.
|
||||
await this.services.team.requireOwnedTeam(uid, userId);
|
||||
const target = await this.#requireTargetUserId(req);
|
||||
|
||||
await this.services.team.deleteMember(uid, userId, target);
|
||||
@@ -406,6 +440,18 @@ export class TeamController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-allowlist gate, on the two routes that enter the feature. Same
|
||||
* 404 as a teams-off deployment; other routes bound by membership.
|
||||
*/
|
||||
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
|
||||
const email = (
|
||||
req.actor as { user?: { email?: string | null } } | undefined
|
||||
)?.user?.email;
|
||||
if (await this.services.team.teamsAvailableTo(userId, email)) return;
|
||||
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
|
||||
}
|
||||
|
||||
#requireUserId(req: Request): number {
|
||||
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
|
||||
?.id;
|
||||
@@ -426,6 +472,17 @@ export class TeamController extends PuterController {
|
||||
return (req.body ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Absent or empty means "not given"; a wrong type is still a 400. */
|
||||
#optionalString(value: unknown, field: string): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, `${field} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
|
||||
#requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new HttpError(400, `${field} is required`, {
|
||||
|
||||
@@ -32,24 +32,8 @@ import type {
|
||||
WithControllerRegistration,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Extension-augmentable controller registry. Extensions add their own
|
||||
* controller instance types via TypeScript declaration merging:
|
||||
*
|
||||
* declare module '@heyputer/backend/controllers/types' {
|
||||
* interface IExtensionControllerInstances {
|
||||
* myController: MyController;
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Augmentations flow into the `extension.import('controller')` proxy.
|
||||
*/
|
||||
/** Extension-augmentable controller registry; see `IExtensionClientInstances`. */
|
||||
export interface IExtensionControllerInstances {
|
||||
/**
|
||||
* Open index signature so reads of extension-only controller keys return
|
||||
* `unknown` instead of a type error. Concrete declaration-merged keys
|
||||
* override this for that name.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -64,11 +48,8 @@ export type IPuterController<
|
||||
) => T;
|
||||
|
||||
/**
|
||||
* Base class for v2 controllers. `registerRoutes(router)` receives a
|
||||
* `PuterRouter` (not an express app) — see `core/http/PuterRouter.ts`.
|
||||
* Controllers either override `registerRoutes` imperatively or lean on the
|
||||
* `@Controller` / `@Post` / etc. decorators, which install a default
|
||||
* `registerRoutes` walker on the prototype.
|
||||
* Base class for controllers. Override `registerRoutes(router)` or use the
|
||||
* `@Controller` / `@Post` decorators, which install a default one.
|
||||
*/
|
||||
export const PuterController =
|
||||
class PuterController implements WithControllerRegistration {
|
||||
@@ -92,7 +73,8 @@ export const PuterController =
|
||||
public onServerShutdown() {
|
||||
return;
|
||||
}
|
||||
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public getReportedCosts():
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[] {
|
||||
|
||||
+41
-44
@@ -25,58 +25,43 @@ export interface ActorApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-token wrapper. When set, this actor is acting _through_ an access
|
||||
* token issued by `issuer`. The token's row in `access_token_permissions` gates
|
||||
* which permissions of the issuer it can exercise.
|
||||
* Set when the actor acts through an access token issued by `issuer`; the
|
||||
* token's `access_token_permissions` rows gate which of the issuer's
|
||||
* permissions it may exercise.
|
||||
*/
|
||||
export interface ActorAccessToken {
|
||||
uid: string;
|
||||
issuer: Actor;
|
||||
authorized?: Actor | null;
|
||||
/**
|
||||
* Full-API-access ("personal access token") flag, set from the signed
|
||||
* `full_access` JWT claim. Such a token may exercise everything its issuing
|
||||
* user can do via the API and is admitted past `requireNonAccessTokenGate`
|
||||
* — but is still rejected by `requireUserActor` / web-session gates, so it
|
||||
* can never manage the account. Normal (scoped) access tokens leave this
|
||||
* false and remain blocked from non-`allowAccessToken` routes.
|
||||
* Personal access token (signed `full_access` claim): full API reach, but
|
||||
* still rejected by `requireUserActor`, so never account management.
|
||||
*/
|
||||
fullAccess?: boolean;
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
user: Partial<UserRow>;
|
||||
/**
|
||||
* The app this actor carries _directly_, empty on an access token an app
|
||||
* issued. Answers "is this an app acting as itself" — `isAppActor` is
|
||||
* usually the clearer way to ask — and never "which app is acting", which
|
||||
* reads as "no app" for a token and falls open.
|
||||
*/
|
||||
app?: ActorApp | null;
|
||||
/**
|
||||
* The app this actor ultimately acts as: its own `app`, or failing that the
|
||||
* app of whoever issued its access token.
|
||||
*
|
||||
* Read this — not `app` — in any gate asking "which app is doing this?". An
|
||||
* access-token actor carries no `app` of its own, so `app` alone reads as
|
||||
* "no app" even for a token an app minted, and a gate keyed off it fails
|
||||
* open exactly where it must not.
|
||||
*
|
||||
* `null` and `undefined` are not the same thing here:
|
||||
*
|
||||
* - `null` — resolved, and this actor is not acting as any app.
|
||||
* - Absent — never resolved, because the actor skipped `makeActor`.
|
||||
*
|
||||
* A gate must not read the second as the first: that is the fail-open this
|
||||
* field exists to prevent. Optional only so an actor literal that predates
|
||||
* the field still compiles; `assertResolvedActor` is what keeps the request
|
||||
* path honest, and `makeActor` is the one place the derivation lives.
|
||||
* The app this actor ultimately acts as: its own `app`, else the app of its
|
||||
* access token's issuer. Gates asking "which app" must read this, not
|
||||
* `app`. `null` means resolved to no app; `undefined` means the actor
|
||||
* skipped `makeActor` and must not be read as "no app".
|
||||
*/
|
||||
effectiveApp?: ActorApp | null;
|
||||
/** True for the system actor; skips metering / quota tracking. */
|
||||
system?: boolean;
|
||||
accessToken?: ActorAccessToken | null;
|
||||
/**
|
||||
* Session reference when authenticated via a session token (user actors) or
|
||||
* an app-under-user token that carries a session. Absent for system,
|
||||
* raw-app, and pure access-token actors. Used for session introspection and
|
||||
* targeted logout. `kind` mirrors the session row's kind (e.g. 'web',
|
||||
* 'app', 'worker') so callers can gate on how the credential was minted
|
||||
* without an extra session lookup.
|
||||
* Set when authenticated by a session token or an app-under-user token
|
||||
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
|
||||
*/
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
}
|
||||
@@ -92,11 +77,8 @@ export const SYSTEM_ACTOR: Actor = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an actor, deriving `effectiveApp` from its own app and, failing that,
|
||||
* from the app of whoever issued its access token.
|
||||
*
|
||||
* The issuer was itself built here, so its chain is already collapsed — one hop
|
||||
* is enough, and no caller has to walk anything.
|
||||
* Build an actor with `effectiveApp` derived; the issuer is already collapsed,
|
||||
* so one hop suffices.
|
||||
*/
|
||||
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
|
||||
...actor,
|
||||
@@ -104,13 +86,8 @@ export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* Fail closed on an actor whose `effectiveApp` was never derived.
|
||||
*
|
||||
* Every actor on the request path is built by `AuthService` through
|
||||
* `makeActor`, so this cannot fire in production — which is the point. It turns
|
||||
* a future actor literal that skips the builder into a loud 500 at the edge
|
||||
* rather than a silent bypass deep inside a gate that read `undefined` as "no
|
||||
* app". Call it once, where the request actor is established.
|
||||
* Fail closed on an actor that skipped `makeActor`, at the edge rather than
|
||||
* inside a gate reading `undefined` as "no app".
|
||||
*/
|
||||
export const assertResolvedActor = (actor: Actor): Actor => {
|
||||
if (actor.effectiveApp === undefined) {
|
||||
@@ -130,12 +107,32 @@ export const isAppActor = (actor: Actor | undefined | null): boolean => {
|
||||
return !!actor?.app && !isAccessTokenActor(actor);
|
||||
};
|
||||
|
||||
/**
|
||||
* The account acting as itself, through nothing: no app and no token of any
|
||||
* kind. Narrower than `isAccountContext`, which also admits a full-access token
|
||||
* — use this where the distinction is "a browser session" rather than "the
|
||||
* account's own reach".
|
||||
*/
|
||||
export const isPlainUserActor = (
|
||||
actor: Pick<Actor, 'app' | 'accessToken'> | undefined | null,
|
||||
): boolean => !!actor && !actor.app && !actor.accessToken;
|
||||
|
||||
export const isAccessTokenActor = (
|
||||
actor: Actor | undefined | null,
|
||||
): boolean => {
|
||||
return !!actor?.accessToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the actor holds the account's own reach: a plain session or a
|
||||
* full-access token. Use this, not `effectiveApp === null`, which would also
|
||||
* admit scoped access tokens. Unresolved actors answer no.
|
||||
*/
|
||||
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
|
||||
if (!actor || actor.effectiveApp !== null) return false;
|
||||
return !actor.accessToken || actor.accessToken.fullAccess === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
|
||||
* cache) and for cycle detection.
|
||||
|
||||
@@ -59,23 +59,14 @@ const getOrInitRoutes = (proto: DecoratedPrototype): CollectedRoute[] => {
|
||||
|
||||
// -- @Controller -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Class decorator.
|
||||
*
|
||||
* - Stores the controller's path `prefix` on the prototype so `PuterServer` can
|
||||
* construct a correctly-prefixed `PuterRouter` for this controller.
|
||||
* - Installs a default `registerRoutes(router)` on the prototype that walks
|
||||
* routes collected by method decorators (if this class hasn't defined its own
|
||||
* `registerRoutes`). This means a purely-decorated controller needs no body —
|
||||
* the decorators do all the wiring.
|
||||
*
|
||||
* Controllers that define their own `registerRoutes` are untouched; they can
|
||||
* still use `@Post` etc. and walk `prototype[ROUTES_METADATA_KEY]` manually if
|
||||
* they want to combine the styles.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyCtor = new (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* Class decorator. Stores `prefix` on the prototype for `PuterServer`, and
|
||||
* installs a default `registerRoutes` that walks decorator-collected routes
|
||||
* unless the class defines its own.
|
||||
*/
|
||||
export function Controller(prefix: string = '') {
|
||||
return <T extends AnyCtor>(
|
||||
value: T,
|
||||
@@ -84,9 +75,7 @@ export function Controller(prefix: string = '') {
|
||||
const proto = value.prototype as DecoratedPrototype;
|
||||
proto[PREFIX_METADATA_KEY] = prefix;
|
||||
|
||||
// Only install the default walker if the class itself hasn't
|
||||
// defined registerRoutes. We check *own* properties (not inherited)
|
||||
// so a PuterController base-class default doesn't block us.
|
||||
// Own property only, so a base-class default doesn't block the walker.
|
||||
const hasOwnRegister = Object.prototype.hasOwnProperty.call(
|
||||
proto,
|
||||
'registerRoutes',
|
||||
|
||||
@@ -61,10 +61,8 @@ declare global {
|
||||
reason: 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
/**
|
||||
* Short-lived server-signed JWT that proves the bearer was
|
||||
* identified as `auth_id` by the rejected session. The GUI must
|
||||
* echo this back (not the raw `auth_id`) on the next
|
||||
* login/signup so the controller can rebind to the same user.
|
||||
* Short-lived JWT proving the bearer was `auth_id`; the GUI
|
||||
* echoes it on the next login to rebind.
|
||||
*/
|
||||
reauth_token?: string;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../actor';
|
||||
import { PERIOD_ESCAPE } from '../../../services/metering/consts.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type { UsageByType } from '../../../services/metering/types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
|
||||
|
||||
@@ -31,12 +32,24 @@ import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
|
||||
*/
|
||||
describe('egress metering over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let originalCacheMs: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
// A sharded month caches getActorCurrentMonthUsageDetails's detail
|
||||
// read for USAGE_DETAIL_CACHE_MS — these tests read before and after
|
||||
// one request a moment apart, well inside that window, so the cache
|
||||
// would mask the very update they're checking for.
|
||||
const ctor = env.server.services.metering
|
||||
.constructor as typeof MeteringService;
|
||||
originalCacheMs = ctor.USAGE_DETAIL_CACHE_MS;
|
||||
ctor.USAGE_DETAIL_CACHE_MS = 0;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
(
|
||||
env.server.services.metering.constructor as typeof MeteringService
|
||||
).USAGE_DETAIL_CACHE_MS = originalCacheMs;
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetCardVerificationStatusCache } from '../../../util/cardFallback';
|
||||
import { makeActor, type Actor } from '../../actor';
|
||||
import { HttpError, isHttpError } from '../HttpError';
|
||||
import {
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
allowedAppIdsGate,
|
||||
assertNotUserSession,
|
||||
noUserSessionGate,
|
||||
requireAnyVerifiedGate,
|
||||
type AnyVerifiedDeps,
|
||||
requireAuthGate,
|
||||
requireCardVerifiedGate,
|
||||
requirePhoneVerifiedGate,
|
||||
@@ -88,6 +91,23 @@ const runGate = (
|
||||
return captured;
|
||||
};
|
||||
|
||||
/** `runGate` for a gate that answers asynchronously. */
|
||||
const runGateAsync = (
|
||||
gate: (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: (arg?: unknown) => void,
|
||||
) => unknown,
|
||||
req: Partial<Request>,
|
||||
): Promise<NextArg> => {
|
||||
if (req.actor) req = { ...req, actor: reviveActor(req.actor) };
|
||||
return new Promise((resolve) =>
|
||||
gate(req as Request, {} as Response, (arg?: unknown) =>
|
||||
resolve(arg as NextArg),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => {
|
||||
expect(isHttpError(got)).toBe(true);
|
||||
const err = got as HttpError;
|
||||
@@ -815,11 +835,10 @@ describe('allowedAppIdsGate', () => {
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes any access token issued by an app, allowed or not', () => {
|
||||
// The check reads `actor.app`, which an access-token actor never
|
||||
// carries — its app lives on `effectiveApp`, one hop through the
|
||||
// issuer. Both directions pass, which is why an appId-gated route
|
||||
// cannot treat this gate as proof of app identity.
|
||||
it('judges an access token by the app that issued it', () => {
|
||||
// An access-token actor carries no app of its own — its app is one hop
|
||||
// down the chain, through the issuer — so reading the direct `app`
|
||||
// would let a token from any app through an appId-gated route.
|
||||
const gate = allowedAppIdsGate(['app-allowed']);
|
||||
const tokenIssuedBy = (appUid: string) => ({
|
||||
actor: {
|
||||
@@ -834,7 +853,9 @@ describe('allowedAppIdsGate', () => {
|
||||
},
|
||||
});
|
||||
expect(runGate(gate, tokenIssuedBy('app-allowed'))).toBeUndefined();
|
||||
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeUndefined();
|
||||
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeInstanceOf(
|
||||
HttpError,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes when the actor.app.uid is in the allow-list', () => {
|
||||
@@ -947,22 +968,31 @@ describe('requirePhoneVerifiedGate', () => {
|
||||
});
|
||||
|
||||
describe('requireCardVerifiedGate', () => {
|
||||
it('passes a user with a verified card on file', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
const plan = (paid: boolean) => ({ hasPaidPlan: async () => paid });
|
||||
|
||||
it('passes a user with a verified card on file', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('passes a paying account as card-verified', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(true)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a user who never verified a card', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
|
||||
it('rejects a user still carrying the card gate', () => {
|
||||
const got = runGate(requireCardVerifiedGate(), {
|
||||
it('rejects a user still carrying the card gate', async () => {
|
||||
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -974,3 +1004,137 @@ describe('requireCardVerifiedGate', () => {
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireAnyVerifiedGate ──────────────────────────────────────────
|
||||
|
||||
describe('requireAnyVerifiedGate', () => {
|
||||
// The card probe is memoized module-wide; every case starts cold.
|
||||
beforeEach(() => resetCardVerificationStatusCache());
|
||||
|
||||
const deps = (
|
||||
sms: boolean,
|
||||
card: boolean | null,
|
||||
paid = false,
|
||||
): AnyVerifiedDeps => ({
|
||||
smsConfigured: () => sms,
|
||||
probeCardVerification: async () => card,
|
||||
hasPaidPlan: async () => paid,
|
||||
});
|
||||
|
||||
const both = ['phone', 'card'] as const;
|
||||
|
||||
it('is inert where neither factor can be verified', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes a verified number whatever the deployment can do today', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, null)),
|
||||
{ actor: { user: { uuid: 'u-1', phone: '+15550000000' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes a verified card', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes a paid plan as the card, verified by other means', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not let a paid plan stand in for a phone', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['phone'], deps(true, true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
|
||||
it('asks about the plan only once the row itself has not answered', async () => {
|
||||
let asked = 0;
|
||||
const counting: AnyVerifiedDeps = {
|
||||
...deps(true, true),
|
||||
hasPaidPlan: async () => {
|
||||
asked++;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1', phone: '+15550000000' } },
|
||||
});
|
||||
expect(asked).toBe(0);
|
||||
await runGateAsync(requireAnyVerifiedGate(both, counting), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expect(asked).toBe(1);
|
||||
});
|
||||
|
||||
it('leads with the phone flow and names both factors when both are verifiable', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({
|
||||
factors: ['phone', 'card'],
|
||||
});
|
||||
});
|
||||
|
||||
it('asks only for the factors the deployment can verify', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(false, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({ factors: ['card'] });
|
||||
});
|
||||
|
||||
it('takes the lead factor from the route order', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(['card', 'phone'], deps(true, true)),
|
||||
{ actor: { user: { uuid: 'u-1' } } },
|
||||
);
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({
|
||||
factors: ['card', 'phone'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count a factor still mid-verification', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
phone: '+15550000000',
|
||||
requires_phone_verification: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
expect((got as HttpError).fields).toEqual({ factors: ['phone'] });
|
||||
});
|
||||
|
||||
it('rejects when there is no actor at all', async () => {
|
||||
const got = await runGateAsync(
|
||||
requireAnyVerifiedGate(both, deps(true, null)),
|
||||
{},
|
||||
);
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,13 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { Actor } from '../../actor';
|
||||
import {
|
||||
isCardVerificationEnabled,
|
||||
type CardFallbackDeps,
|
||||
} from '../../../util/cardFallback';
|
||||
import { isAppActor, isPlainUserActor, type Actor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import type { AccountGateUser, VerificationFactor } from '../types';
|
||||
import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
@@ -70,14 +75,7 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject anonymous requests with 401. Also reject authenticated-but-suspended
|
||||
* users with 403 — `actor.user.suspended` is populated by `AuthService` from
|
||||
* `UserStore`, so the gate doesn't need its own DB hit.
|
||||
*
|
||||
* Implied by `requireUserActor`, `adminOnly`, and `allowedAppIds`; the
|
||||
* materializer ensures only one copy ends up in the chain.
|
||||
*/
|
||||
/** 401 for anonymous requests, 403 for suspended accounts. */
|
||||
export const requireAuthGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
if (req.appBlocked) {
|
||||
@@ -105,17 +103,10 @@ export const requireAuthGate = (): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject app-under-user and access-token actors with 403. Use on endpoints that
|
||||
* should only be exercised by a human session — settings changes, admin-style
|
||||
* actions on the user's own account.
|
||||
*
|
||||
* `allowFullAccess` (set per-route via the `allowFullAccessToken` route option)
|
||||
* relaxes ONLY the access-token half: a full-access ("personal access token")
|
||||
* actor is admitted, because it represents the user's own full API reach.
|
||||
* Third-party apps are ALWAYS rejected, and scoped access tokens are always
|
||||
* rejected. This opt-in is for user-resource / inference endpoints (AI proxy,
|
||||
* etc.) that use this gate purely to keep apps out — NEVER for account or
|
||||
* security management, which must stay closed to every access token.
|
||||
* Reject app-under-user and access-token actors with 403. `allowFullAccess`
|
||||
* (route option `allowFullAccessToken`) admits full-access personal access
|
||||
* tokens only; apps and scoped tokens are always rejected. Never set it on
|
||||
* account or security management routes.
|
||||
*/
|
||||
export const requireUserActorGate = (
|
||||
opts: { allowFullAccess?: boolean } = {},
|
||||
@@ -127,10 +118,7 @@ export const requireUserActorGate = (
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
// Third-party apps are never allowed through this gate.
|
||||
const appBlocked = !!actor.app;
|
||||
// Access tokens are blocked unless the route opted in AND this is a
|
||||
// full-access PAT (the user's own credential). Scoped tokens: blocked.
|
||||
const appBlocked = isAppActor(actor);
|
||||
const tokenBlocked =
|
||||
!!actor.accessToken &&
|
||||
!(opts.allowFullAccess && actor.accessToken.fullAccess);
|
||||
@@ -149,30 +137,18 @@ export const requireUserActorGate = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors — the "root" credential a browser session (or
|
||||
* `/login`) holds, with no app and no access token in play. Use on API surfaces
|
||||
* that must only be driven by a delegated credential: an app or worker token,
|
||||
* or an API token minted from the dashboard. The point is that a
|
||||
* leaked-or-copied session token (full account control) shouldn't double as an
|
||||
* AI/API credential; users are pushed to mint a revocable token instead.
|
||||
*
|
||||
* This gate only rejects the bare-session shape. Which delegated credentials
|
||||
* are acceptable is decided by the gates it composes with (`requireUserActor`
|
||||
*
|
||||
* - `allowFullAccessToken` to also keep apps out, `requireNonAccessTokenGate` for
|
||||
* scoped tokens, etc.).
|
||||
* Reject bare account-session actors (no app, no access token) so a session
|
||||
* token never doubles as an API credential. Only rejects that shape; which
|
||||
* delegated credentials are acceptable is decided by the gates it composes
|
||||
* with.
|
||||
*/
|
||||
export const assertNotUserSession = (
|
||||
actor: Pick<Actor, 'app' | 'accessToken' | 'session'> | null | undefined,
|
||||
): void => {
|
||||
if (!actor) return; // anonymous requests are the auth gate's problem
|
||||
if (actor.app || actor.accessToken) return;
|
||||
// User-scoped workers (deployed with no app binding) authenticate with
|
||||
// a session-TYPE token whose session row is `kind='worker'` — a managed,
|
||||
// revocable deployment credential, not a browser sign-in. Workers are
|
||||
// never treated as root tokens: this gate is an annoyance for
|
||||
// sign-up-and-scrape abuse, and someone who deploys a worker to reach
|
||||
// an API has already left that path.
|
||||
if (!isPlainUserActor(actor)) return;
|
||||
// App-less workers hold a session-type token with `kind='worker'`: a
|
||||
// revocable deployment credential, not a browser sign-in.
|
||||
if (actor.session?.kind === 'worker') return;
|
||||
throw new HttpError(
|
||||
403,
|
||||
@@ -208,12 +184,8 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
// Full-access ("personal access token") access tokens are admitted here:
|
||||
// they carry the user's full API reach by design. They remain blocked
|
||||
// from account management because those routes also use
|
||||
// `requireUserActorGate`, which rejects ALL access tokens. Normal
|
||||
// (scoped) access tokens stay blocked from non-`allowAccessToken`
|
||||
// routes.
|
||||
// Full-access tokens pass; `requireUserActorGate` still keeps them
|
||||
// off account management.
|
||||
if (actor.accessToken && !actor.accessToken.fullAccess) {
|
||||
next(
|
||||
new HttpError(
|
||||
@@ -232,33 +204,17 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
|
||||
/**
|
||||
* Reject unless `actor.user.username` matches `admin`, `system`, or one of the
|
||||
* supplied extras. Extras are _additional_ allowed users on top of the built-in
|
||||
* pair, not a replacement for it.
|
||||
*
|
||||
* Also requires a _root token_ — an actor with no app anywhere in its token
|
||||
* chain (see `Actor.effectiveApp`) — so a third-party app an admin has
|
||||
* authorized can't reach admin endpoints on the admin's behalf. The one
|
||||
* exception is `appGated`: on a route that is also appId-gated
|
||||
* (`allowedAppIds`), a direct app-under-user actor is deferred to
|
||||
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
|
||||
* scoped to an allowed app". Access tokens issued through an app are rejected
|
||||
* even then — `allowedAppIdsGate` only sees top-level `actor.app` and would
|
||||
* otherwise wave them through.
|
||||
*
|
||||
* Implies `requireAuth`. Does _not_ imply `requireUserActor` — a root token
|
||||
* still includes an admin's full-access personal access token, not only browser
|
||||
* sessions; combine with `requireUserActor` explicitly if a route must be
|
||||
* restricted to browser sessions.
|
||||
* Reject unless the username is `admin`, `system`, or one of `extras`, and the
|
||||
* actor carries no app anywhere in its token chain (`effectiveApp`). With
|
||||
* `appGated` a direct app-under-user actor is deferred to `allowedAppIdsGate`
|
||||
* instead; app-issued access tokens are rejected even then, since that gate
|
||||
* cannot see them. Does not imply `requireUserActor`.
|
||||
*/
|
||||
export const adminOnlyGate = (
|
||||
extras: readonly string[] = [],
|
||||
opts: { appGated?: boolean } = {},
|
||||
): RequestHandler => {
|
||||
// Match the case-insensitivity guarantee of the username column
|
||||
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
|
||||
// raw-case here would let a stored `Admin` bypass the lowercase allowlist
|
||||
// on any backend that lets case-collision rows exist.
|
||||
// Match the username column's case-insensitive collation.
|
||||
const allowList = new Set<string>(
|
||||
[...DEFAULT_ADMIN_USERNAMES, ...extras].map((u) => u.toLowerCase()),
|
||||
);
|
||||
@@ -272,13 +228,8 @@ export const adminOnlyGate = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Root-token requirement: reject actors carrying an app anywhere in
|
||||
// their token chain — app-under-user, or an access token issued
|
||||
// through an app. A direct app-under-user actor is deferred to
|
||||
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
|
||||
// are rejected even then, since that gate can't see them.
|
||||
const chainApp = req.actor?.effectiveApp ?? null;
|
||||
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
|
||||
if (chainApp && !(opts.appGated && isAppActor(req.actor))) {
|
||||
next(
|
||||
new HttpError(403, 'Only admins may request this resource', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -291,12 +242,9 @@ export const adminOnlyGate = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the authenticated user has a confirmed email. Gated behind
|
||||
* `strict_email_verification_required` config so self-hosted deployments
|
||||
* without email delivery don't brick their own filesystem routes.
|
||||
*
|
||||
* Reads `req.actor?.user?.email_confirmed`, which is present on both user-only
|
||||
* and app-under-user actors, so it works for either shape.
|
||||
* Reject unless the user's email is confirmed. Inert unless
|
||||
* `strict_email_verification_required` is set, so deployments without email
|
||||
* delivery are not locked out.
|
||||
*/
|
||||
export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
@@ -311,24 +259,9 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject authenticated users whose account is still pending any signup-time
|
||||
* verification — email confirmation, SMS phone verification, or credit-card
|
||||
* verification. The abuse harness sets the phone/card flags on low-reputation
|
||||
* signups (in place of a hard block), and this gate is what actually keeps
|
||||
* those accounts out of the product until the flag clears: the flags live on
|
||||
* `req.actor.user`, so every authenticated route enforces them, not just the
|
||||
* GUI modal.
|
||||
*
|
||||
* Runs on every authenticated route by default; routes that set
|
||||
* `allowUnconfirmed: true` opt out (the verification endpoints themselves, plus
|
||||
* essential flows like whoami / logout / save-account so a pending account can
|
||||
* still reach the screens that clear the gate).
|
||||
*
|
||||
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required` /
|
||||
* `password_change_required`) so clients can show the right prompt instead of a
|
||||
* generic error. There is no state where a user should be allowed in with one
|
||||
* verification pending, so any pending gate rejects.
|
||||
* Reject accounts still pending a signup-time verification (email, phone, card,
|
||||
* password change). Default-on for authenticated routes; the flows that clear
|
||||
* these flags opt out with `allowUnconfirmed: true`.
|
||||
*/
|
||||
export const requireVerifiedAccount = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
@@ -343,31 +276,12 @@ export const requireVerifiedAccount = (): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The pending-verification check, factored out of {@link requireVerifiedAccount}
|
||||
* so auth paths that build their own actor outside the route-option machinery
|
||||
* can enforce the exact same gate. The WebDAV controller is the motivating
|
||||
* case: it dispatches every method off a single `router.use`, so
|
||||
* `requireVerifiedAccount` is never wired into its chain — it has to call this
|
||||
* directly. Keeping one implementation is the point: a verification gate added
|
||||
* here is picked up by every caller, so the paths can't drift (which is how
|
||||
* WebDAV came to bypass the phone/card gate to begin with).
|
||||
*
|
||||
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required` /
|
||||
* `password_change_required`) so clients can show the right prompt instead of a
|
||||
* generic error. There is no state where a user should be let in with any
|
||||
* verification pending, so the first pending gate rejects.
|
||||
* The check behind {@link requireVerifiedAccount}, for auth paths that build
|
||||
* their own actor outside the route-option chain (e.g. WebDAV). Throws 403 with
|
||||
* a per-gate legacy code so clients can show the right prompt.
|
||||
*/
|
||||
export const assertVerifiedAccount = (
|
||||
user:
|
||||
| {
|
||||
requires_email_confirmation?: unknown;
|
||||
email_confirmed?: unknown;
|
||||
requires_phone_verification?: unknown;
|
||||
requires_card_verification?: unknown;
|
||||
requires_password_change?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
user: AccountGateUser | undefined,
|
||||
): void => {
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
throw new HttpError(403, 'Please confirm your email to continue', {
|
||||
@@ -403,32 +317,27 @@ export const assertVerifiedAccount = (
|
||||
|
||||
// -- Per-verification gates ------------------------------------------
|
||||
//
|
||||
// `requireVerifiedAccount` above is the default-on gate: it turns away an
|
||||
// account that is *pending* a verification the abuse harness asked of it, and
|
||||
// says nothing about accounts that were never asked. The two gates below are
|
||||
// the opt-in counterparts of `requireVerified` (email) for the other two
|
||||
// factors: they require the verification to have actually happened, whether or
|
||||
// not this account was ever flagged for it.
|
||||
//
|
||||
// Neither factor has a boolean "verified" column — the pending flag is cleared
|
||||
// on success and the proof is the artifact left behind. `phone` is written only
|
||||
// once a code has been confirmed, and `card_fingerprint` only once a card has
|
||||
// been checked (both are also what `UserAccountService` reads as the
|
||||
// phone-verified / card-verified signals). Pending flag clear AND artifact
|
||||
// present is therefore the honest reading of "verified", and an account still
|
||||
// mid-flow fails on the flag rather than slipping through on a stale artifact.
|
||||
// Opt-in gates requiring a factor to have actually been verified, unlike
|
||||
// `requireVerifiedAccount` which only rejects accounts still pending one.
|
||||
// There is no "verified" column: the proof is the artifact (`phone`,
|
||||
// `card_fingerprint`) plus a cleared pending flag, so an account mid-flow
|
||||
// fails on the flag rather than passing on a stale artifact.
|
||||
|
||||
/**
|
||||
* Reject unless the user has a phone number verified on file. 403
|
||||
* `phone_verification_required`, the same code the pending-verification gate
|
||||
* uses, so a client that already prompts for the SMS flow prompts here too.
|
||||
*/
|
||||
export const hasVerifiedPhone = (user: AccountGateUser | undefined): boolean =>
|
||||
Boolean(user?.phone) && !user?.requires_phone_verification;
|
||||
|
||||
export const hasVerifiedCard = (user: AccountGateUser | undefined): boolean =>
|
||||
Boolean(user?.card_fingerprint) && !user?.requires_card_verification;
|
||||
|
||||
const PHONE_REQUIRED_MESSAGE = 'Please verify your phone number to continue';
|
||||
const CARD_REQUIRED_MESSAGE = 'Please verify your card to continue';
|
||||
|
||||
/** 403 `phone_verification_required` unless a verified phone is on file. */
|
||||
export const assertPhoneVerified = (
|
||||
user:
|
||||
{ phone?: unknown; requires_phone_verification?: unknown } | undefined,
|
||||
user: AccountGateUser | undefined,
|
||||
): void => {
|
||||
if (user?.phone && !user?.requires_phone_verification) return;
|
||||
throw new HttpError(403, 'Please verify your phone number to continue', {
|
||||
if (hasVerifiedPhone(user)) return;
|
||||
throw new HttpError(403, PHONE_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'phone_verification_required',
|
||||
});
|
||||
};
|
||||
@@ -446,38 +355,107 @@ export const requirePhoneVerifiedGate = (): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the user has a card verified on file. 403
|
||||
* `card_verification_required`, matching the pending-verification gate so the
|
||||
* client shows the card flow it already has.
|
||||
*/
|
||||
export const assertCardVerified = (
|
||||
user:
|
||||
| { card_fingerprint?: unknown; requires_card_verification?: unknown }
|
||||
| undefined,
|
||||
): void => {
|
||||
if (user?.card_fingerprint && !user?.requires_card_verification) return;
|
||||
throw new HttpError(403, 'Please verify your card to continue', {
|
||||
legacyCode: 'card_verification_required',
|
||||
});
|
||||
};
|
||||
export interface AnyVerifiedDeps extends CardFallbackDeps {
|
||||
/** A paid plan implies a card on file, so it counts as card-verified. */
|
||||
hasPaidPlan: (actor: Actor) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Route-option form of {@link assertCardVerified} (`requireCardVerified`). */
|
||||
export const requireCardVerifiedGate = (): RequestHandler => {
|
||||
const hasCardEvidence = async (
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
hasVerifiedCard(actor?.user) ||
|
||||
(actor !== undefined && (await deps.hasPaidPlan(actor)));
|
||||
|
||||
/**
|
||||
* 403 `card_verification_required` unless a card is on file or the plan is
|
||||
* paid.
|
||||
*/
|
||||
export const requireCardVerifiedGate = (
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
assertCardVerified(req.actor?.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
hasCardEvidence(req.actor, deps).then((verified) => {
|
||||
if (verified) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(
|
||||
new HttpError(403, CARD_REQUIRED_MESSAGE, {
|
||||
legacyCode: 'card_verification_required',
|
||||
}),
|
||||
);
|
||||
}, next);
|
||||
};
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (
|
||||
user: { suspended?: unknown } | undefined,
|
||||
): void => {
|
||||
const isFactorVerified = (
|
||||
factor: VerificationFactor,
|
||||
actor: Actor | undefined,
|
||||
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
|
||||
): Promise<boolean> =>
|
||||
factor === 'phone'
|
||||
? Promise.resolve(hasVerifiedPhone(actor?.user))
|
||||
: hasCardEvidence(actor, deps);
|
||||
|
||||
/** Whether this deployment can run the factor's verification flow at all. */
|
||||
const isFactorVerifiable = async (
|
||||
factor: VerificationFactor,
|
||||
deps: CardFallbackDeps,
|
||||
): Promise<boolean> =>
|
||||
factor === 'phone'
|
||||
? deps.smsConfigured()
|
||||
: (await isCardVerificationEnabled(deps)) === true;
|
||||
|
||||
/**
|
||||
* Reject unless at least one of `factors` is verified. Only factors this
|
||||
* deployment can verify are asked for, so with none verifiable the gate is
|
||||
* inert. The 403 carries the first verifiable factor's code and lists every
|
||||
* verifiable one in `factors`.
|
||||
*/
|
||||
export const assertAnyVerified = async (
|
||||
actor: Actor | undefined,
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: AnyVerifiedDeps,
|
||||
): Promise<void> => {
|
||||
// In the route's order, so a verified phone never costs the plan lookup.
|
||||
for (const factor of factors) {
|
||||
if (await isFactorVerified(factor, actor, deps)) return;
|
||||
}
|
||||
const verifiable: VerificationFactor[] = [];
|
||||
for (const factor of factors) {
|
||||
if (verifiable.includes(factor)) continue;
|
||||
if (await isFactorVerifiable(factor, deps)) verifiable.push(factor);
|
||||
}
|
||||
if (verifiable.length === 0) return;
|
||||
const [lead] = verifiable;
|
||||
throw new HttpError(
|
||||
403,
|
||||
lead === 'phone' ? PHONE_REQUIRED_MESSAGE : CARD_REQUIRED_MESSAGE,
|
||||
{
|
||||
legacyCode:
|
||||
lead === 'phone'
|
||||
? 'phone_verification_required'
|
||||
: 'card_verification_required',
|
||||
fields: { factors: verifiable },
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Route-option form of {@link assertAnyVerified} (`requireAnyVerified`). */
|
||||
export const requireAnyVerifiedGate = (
|
||||
factors: readonly VerificationFactor[],
|
||||
deps: AnyVerifiedDeps,
|
||||
): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
assertAnyVerified(req.actor, factors, deps).then(
|
||||
() => next(),
|
||||
(err) => next(err),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (user: AccountGateUser | undefined): void => {
|
||||
if (user?.suspended) {
|
||||
throw new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
@@ -486,33 +464,17 @@ export const assertNotSuspended = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow WHICH apps may reach a route: an app-under-user actor whose
|
||||
* `actor.app.uid` is missing from the allowList is rejected with 403.
|
||||
*
|
||||
* It is NOT an app gate, despite the name. Every actor with no `app` of its own
|
||||
* passes through — browser sessions, full-access personal access tokens, worker
|
||||
* sessions, and access tokens issued BY an app, since the check reads
|
||||
* `actor.app` and never `actor.effectiveApp`, leaving an app in the token chain
|
||||
* invisible here. Routes depend on that pass-through: `adminOnly` +
|
||||
* `allowedAppIds` means "a root token OR a token scoped to an allowed app", and
|
||||
* the admin and dev-account surfaces are called both ways.
|
||||
*
|
||||
* So use it to keep other apps out, never as proof that an app is present. The
|
||||
* default-on `requireNonAccessTokenGate` is what keeps app-issued tokens off
|
||||
* these routes; a route setting `allowAccessToken` loses that cover and has to
|
||||
* check `effectiveApp` itself.
|
||||
*
|
||||
* Implies `requireAuth`. Pairing it with `requireUserActor` leaves the
|
||||
* allowList dead — that gate rejects every app, and everything it admits passes
|
||||
* here.
|
||||
* Reject actors acting as an app that is not allow-listed — the app they carry
|
||||
* directly, or the one that issued their access token. Actors with no app
|
||||
* anywhere in the chain pass. Use it to keep other apps out, never as proof an
|
||||
* app is present.
|
||||
*/
|
||||
export const allowedAppIdsGate = (
|
||||
allowedAppUids: readonly string[],
|
||||
): RequestHandler => {
|
||||
const allowList = new Set(allowedAppUids);
|
||||
return (req, _res, next) => {
|
||||
// Deliberately `app`, not `effectiveApp`: see the note above.
|
||||
const appUid = req.actor?.app?.uid;
|
||||
const appUid = req.actor?.effectiveApp?.uid;
|
||||
if (appUid && !allowList.has(appUid)) {
|
||||
next(
|
||||
new HttpError(403, 'This app may not request this resource', {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { Request } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../actor';
|
||||
import type { AuthService } from '../../../services/auth/AuthService';
|
||||
import { PuterServer } from '../../../server';
|
||||
import { setupTestServer } from '../../../testUtil';
|
||||
@@ -678,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the private app over an older public bootstrap stub on an alternate host', async () => {
|
||||
const owner = await makeUser();
|
||||
await server.stores.app.createFromOrigin(
|
||||
`app-${uuidv4()}`,
|
||||
'http://beans.host.puter.localhost',
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
const app = await createPrivateApp(
|
||||
owner.id,
|
||||
'http://beans.app.puter.localhost/',
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
expect(Boolean(out?.is_private)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when the site has no owner', async () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
@@ -780,7 +802,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores req.actor whose actor.app.uid doesn't match the target app", async () => {
|
||||
it("ignores req.actor whose app doesn't match the target app", async () => {
|
||||
// Cross-app token confusion guard: an app-under-user actor whose
|
||||
// issuing app is NOT the private host's app must not establish
|
||||
// identity here — even though the underlying user may have legit
|
||||
@@ -791,11 +813,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: `app-attacker-${uuidv4()}` },
|
||||
session: { uid: 'sess-1' },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
@@ -805,6 +827,77 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores an app-issued access token minted for a different app', async () => {
|
||||
// An access token carries its issuing app one hop down the chain, on
|
||||
// `effectiveApp`, with no `app` of its own. Reading `app` here would
|
||||
// take it for a portable session token and hand app A's token an
|
||||
// identity on app B's private host.
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: `app-attacker-${uuidv4()}` },
|
||||
});
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: targetUid },
|
||||
});
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it("ignores a bootstrap query-token whose actor.app.uid doesn't match the target app", async () => {
|
||||
// Same guard for the `?puter.auth.token=` bootstrap path. A token
|
||||
// minted via getUserAppToken for the attacker's app carries
|
||||
|
||||
@@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
|
||||
: '';
|
||||
const placeholders = uniqueCandidates.map(() => '?').join(', ');
|
||||
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
|
||||
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
|
||||
const rows = await opts.db.read(
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
|
||||
[opts.site.user_id, ...uniqueCandidates],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
@@ -451,16 +453,21 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* match the host the request is being made against. A token minted for app A —
|
||||
* e.g. when the visitor authorized a third-party app — must not be honored as
|
||||
* identity on app B's private host, even when the underlying user happens to
|
||||
* have entitlement to B. User-only actors (no `actor.app`) are unaffected: a
|
||||
* plain session token is portable by design.
|
||||
* have entitlement to B. User-only actors are unaffected: a plain session token
|
||||
* is portable by design.
|
||||
*
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (!expectedAppUid) return true;
|
||||
if (!actor.app?.uid) return true;
|
||||
return actor.app.uid === expectedAppUid;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
return app.uid === expectedAppUid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { PuterServer } from '../../../server';
|
||||
import { setupTestServer } from '../../../testUtil';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { generateDefaultFsentries } from '../../../util/userProvisioning';
|
||||
import { createPuterSiteMiddleware } from './puterSite';
|
||||
|
||||
@@ -875,6 +876,63 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
// FSService so we exercise parsing, path resolution, and the loop-
|
||||
// prevention guard together.
|
||||
|
||||
describe('createPuterSiteMiddleware — site.access.check', () => {
|
||||
const serveUnder = async (listener?: (data: unknown) => void) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `gate-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/secret.txt`,
|
||||
Buffer.from('shh'),
|
||||
'text/plain',
|
||||
);
|
||||
const seen: unknown[] = [];
|
||||
server.clients.event.on('site.access.check', (_key, data) => {
|
||||
if (data.subdomain !== sub) return;
|
||||
seen.push(data);
|
||||
listener?.(data);
|
||||
});
|
||||
const { out } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/secret.txt',
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return { out, seen, sub };
|
||||
};
|
||||
|
||||
it('asks listeners before streaming and serves when nobody objects', async () => {
|
||||
const { out, seen, sub } = await serveUnder();
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({
|
||||
subdomain: sub,
|
||||
host: `${sub}.site.puter.localhost`,
|
||||
requestPath: '/secret.txt',
|
||||
entry: { name: 'secret.txt' },
|
||||
result: { allowed: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the plain file 404 when a listener withholds the entry', async () => {
|
||||
const { out } = await serveUnder((data) => {
|
||||
(data as { result: { allowed: boolean } }).result.allowed = false;
|
||||
});
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
expect(out.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
const setupSiteWithConfig = async (config: unknown) => {
|
||||
const owner = await makeUserWithHome();
|
||||
@@ -1369,3 +1427,233 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
|
||||
expect(piped!.equals(shell)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — hosting CSP', () => {
|
||||
const POLICY = "script-src * 'unsafe-inline'; object-src 'none'";
|
||||
|
||||
// Serves one file at the site root and returns the captured response.
|
||||
const serve = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
configOverride?: Partial<IConfig>,
|
||||
) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `csp-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/${filename}`,
|
||||
Buffer.from('<p>hi</p>'),
|
||||
contentType,
|
||||
);
|
||||
|
||||
const mw = buildMiddleware(configOverride);
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: `/${filename}`,
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
|
||||
it('sends no CSP header when hosting_csp is unset', async () => {
|
||||
// The default. Hosted sites are third-party apps, so an unconfigured
|
||||
// deployment must not start enforcing a policy on them.
|
||||
const out = await serve('index.html', 'text/html');
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
expect(
|
||||
out.headers['Content-Security-Policy-Report-Only'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('enforces hosting_csp on an active document when configured', async () => {
|
||||
const out = await serve('index.html', 'text/html', {
|
||||
hosting_csp: POLICY,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBe(POLICY);
|
||||
expect(
|
||||
out.headers['Content-Security-Policy-Report-Only'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends the policy as Report-Only when hosting_csp_report_only is set', async () => {
|
||||
const out = await serve('index.html', 'text/html', {
|
||||
hosting_csp: POLICY,
|
||||
hosting_csp_report_only: true,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy-Report-Only']).toBe(POLICY);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves non-active documents alone', async () => {
|
||||
// A CSP does nothing for a stylesheet or an image, and every header
|
||||
// set here ships to apps we don't control.
|
||||
const out = await serve('style.css', 'text/css', {
|
||||
hosting_csp: POLICY,
|
||||
});
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Security-Policy']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Sites rooted in someone else's directory ------------------------
|
||||
//
|
||||
// Hosting serves everything under the site root with the ACL bypassed, so a
|
||||
// site published from a shared folder rides on the `manage` grant that
|
||||
// authorized it. These pin that the grant is re-read on every request, and
|
||||
// that the ordinary owner-rooted site pays nothing for it.
|
||||
|
||||
const actorFor = (user: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
username: string;
|
||||
}): Actor =>
|
||||
({
|
||||
user: { id: user.id, uuid: user.uuid, username: user.username },
|
||||
effectiveApp: null,
|
||||
}) as Actor;
|
||||
|
||||
const descriptorFor = (path: string) => ({
|
||||
path,
|
||||
resolveAncestors: () => server.services.fs.getAncestorChain(path),
|
||||
});
|
||||
|
||||
const serveSite = async (sub: string) => {
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/index.html',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
// Allow the piped stream to flush.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('createPuterSiteMiddleware — delegated site roots', () => {
|
||||
// Owner shares a directory at `manage`; the delegate points a subdomain
|
||||
// at it, which is exactly what `SubdomainDriver` permits.
|
||||
const publishSharedDir = async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const delegate = await makeUserWithHome();
|
||||
const dirPath = `/${owner.username}/Documents`;
|
||||
const dirEntry = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${dirPath}/index.html`,
|
||||
Buffer.from('<html>shared</html>'),
|
||||
'text/html',
|
||||
);
|
||||
await server.services.acl.setUserUser(
|
||||
actorFor(owner),
|
||||
actorFor(delegate),
|
||||
descriptorFor(dirPath),
|
||||
'manage',
|
||||
);
|
||||
const sub = `shared-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: delegate.id,
|
||||
subdomain: sub,
|
||||
rootDirId: dirEntry.id,
|
||||
});
|
||||
return { owner, delegate, dirEntry, sub };
|
||||
};
|
||||
|
||||
it('serves a site whose publisher still holds `manage` on the root', async () => {
|
||||
const { sub } = await publishSharedDir();
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect((out.body as Buffer).toString()).toBe('<html>shared</html>');
|
||||
});
|
||||
|
||||
it("stops serving once the owner withdraws the publisher's `manage` grant", async () => {
|
||||
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
await server.services.permission.revokeUserUserPermission(
|
||||
actorFor(owner),
|
||||
delegate.username,
|
||||
`manage:fs:${dirEntry.uuid}`,
|
||||
);
|
||||
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.contentType).toBe('text/html; charset=UTF-8');
|
||||
expect(String(out.body)).toContain('404');
|
||||
});
|
||||
|
||||
it('stops serving once the owner unshares the directory', async () => {
|
||||
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
await server.services.share.unshare(actorFor(owner), {
|
||||
uid: dirEntry.uuid,
|
||||
recipient: { username: delegate.username },
|
||||
});
|
||||
|
||||
expect((await serveSite(sub)).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('stops serving once the owner moves the root into their Trash', async () => {
|
||||
// The grant is keyed on the node, so it survives the move — the
|
||||
// trashed location is what makes the site unservable.
|
||||
const { owner, dirEntry, sub } = await publishSharedDir();
|
||||
expect((await serveSite(sub)).statusCode).toBe(200);
|
||||
|
||||
const trash = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}/Trash`,
|
||||
))!;
|
||||
await server.services.fs.move(owner.id, {
|
||||
source: dirEntry,
|
||||
destinationParent: trash,
|
||||
});
|
||||
|
||||
expect((await serveSite(sub)).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("never consults the ACL for a site rooted in the publisher's own tree", async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry =
|
||||
(await server.stores.fsEntry.getEntryByPath(homePath))!;
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
Buffer.from('<html>mine</html>'),
|
||||
'text/html',
|
||||
);
|
||||
const sub = `own-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry.id,
|
||||
});
|
||||
|
||||
const aclCheck = vi.spyOn(server.services.acl, 'check');
|
||||
try {
|
||||
const out = await serveSite(sub);
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(aclCheck).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
aclCheck.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,10 @@ import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import type { puterServices } from '../../../services';
|
||||
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
@@ -89,6 +91,32 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
|
||||
.split('/')
|
||||
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
|
||||
/**
|
||||
* Suggested value for `config.hosting_csp`. Not applied unless configured.
|
||||
*
|
||||
* Sources stay wide open because hosted sites legitimately pull from anywhere,
|
||||
* and `'unsafe-inline'` stays because most hosted apps use inline script. What
|
||||
* it withholds is `'unsafe-eval'` — the capability a stale library needs to
|
||||
* reach `new Function`, which is how CVE-2024-4367 turns a crafted PDF into
|
||||
* script execution in the viewer app's own origin.
|
||||
*
|
||||
* Apps that genuinely need eval (some wasm glue, some template engines) will
|
||||
* break under this, which is why it ships as a suggestion rather than a
|
||||
* default. Roll it out with `hosting_csp_report_only` first and read the
|
||||
* violation reports before enforcing.
|
||||
*/
|
||||
export const RECOMMENDED_HOSTING_CSP = [
|
||||
'default-src * data: blob:',
|
||||
"script-src * data: blob: 'unsafe-inline'",
|
||||
"style-src * data: blob: 'unsafe-inline'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
].join('; ');
|
||||
|
||||
const SUBDOMAIN_404 = `<div style="font-size: 20px;
|
||||
text-align: center;
|
||||
height: calc(100vh);
|
||||
@@ -460,6 +488,49 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// A site rooted in someone else's directory stands on the `manage`
|
||||
// grant that authorized publishing it, and serves that whole subtree
|
||||
// with the ACL bypassed — so the grant is re-read on every request.
|
||||
// Trash is its own check: a grant is keyed on the node, not its
|
||||
// location, so it survives the owner deleting the directory. Sites
|
||||
// rooted in their publisher's own tree skip all of this.
|
||||
if (rootEntry.userId !== site.user_id) {
|
||||
let stillPublishable = false;
|
||||
if (!isTrashedPath(rootEntry.path)) {
|
||||
try {
|
||||
stillPublishable = await layers.services.acl.check(
|
||||
makeActor({
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
}),
|
||||
{
|
||||
path: rootEntry.path,
|
||||
resolveAncestors: () =>
|
||||
layers.services.fs.getAncestorChain(
|
||||
rootEntry.path,
|
||||
),
|
||||
},
|
||||
MANAGE_PERM_PREFIX,
|
||||
);
|
||||
} catch (e) {
|
||||
// Fail closed — an unreadable grant is not a held one.
|
||||
console.warn(
|
||||
'[puter-site] publish grant recheck failed',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!stillPublishable) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send(SUBDOMAIN_404);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve URL path → absolute FS path under the site root.
|
||||
let urlPath = req.path || '/';
|
||||
if (urlPath.endsWith('/')) urlPath += 'index.html';
|
||||
@@ -560,6 +631,33 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// A listener owning the site may withhold this entry (a system site
|
||||
// that publishes per-user files conditionally). Denied looks exactly
|
||||
// like missing, and a listener failure denies rather than serves.
|
||||
const accessCheck = {
|
||||
subdomain,
|
||||
host,
|
||||
requestPath: req.path,
|
||||
entry,
|
||||
result: { allowed: true },
|
||||
};
|
||||
try {
|
||||
await layers.clients.event.emitAndWait(
|
||||
'site.access.check',
|
||||
accessCheck,
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[puter-site] site.access.check threw', e);
|
||||
accessCheck.result.allowed = false;
|
||||
}
|
||||
if (!accessCheck.result.allowed) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send('<h1>404</h1><p>Not Found</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream the file. `fsEntry.readContent` honours Range + emits
|
||||
// ETag/Last-Modified when the S3 layer returns them. Range
|
||||
// requests are suppressed when serving a custom error page so
|
||||
@@ -587,7 +685,23 @@ export const createPuterSiteMiddleware = (
|
||||
|
||||
// Fire-and-forget signal for downstream extensions
|
||||
const mimeBase = mime.split(';', 1)[0].trim().toLowerCase();
|
||||
if (mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml') {
|
||||
const isActiveDocument =
|
||||
mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml';
|
||||
|
||||
// Only active documents carry a CSP — it does nothing for an image or
|
||||
// a stylesheet, and every header we set here ships to third-party apps
|
||||
// we don't control. Off unless `hosting_csp` is configured; see
|
||||
// RECOMMENDED_HOSTING_CSP.
|
||||
if (config.hosting_csp && isActiveDocument) {
|
||||
res.setHeader(
|
||||
config.hosting_csp_report_only
|
||||
? 'Content-Security-Policy-Report-Only'
|
||||
: 'Content-Security-Policy',
|
||||
config.hosting_csp,
|
||||
);
|
||||
}
|
||||
|
||||
if (isActiveDocument) {
|
||||
try {
|
||||
const requestUrl = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { FSEntry } from '../../../stores/fs/FSEntry';
|
||||
|
||||
/**
|
||||
* Minimal Redis surface we need — `get` / `set` with EX TTL. Typed as a subset
|
||||
* of ioredis so callers can pass either the real cluster client or a mock
|
||||
* without needing the full Cluster type here.
|
||||
*/
|
||||
/** The subset of ioredis the loader uses, so tests can pass a mock. */
|
||||
export interface SiteConfigCache {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(
|
||||
@@ -35,42 +31,6 @@ export interface SiteConfigCache {
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-level configuration loaded from `.puter_site_config` at the site root.
|
||||
* Lets a hosted site customize how the server responds for it — currently
|
||||
* limited to error-page mapping (e.g. SPA fallback that serves `/index.html`
|
||||
* for any 404 with a 200 status).
|
||||
*
|
||||
* The on-disk JSON shape:
|
||||
*
|
||||
* { "errors": { "404": { "file": "/index.html", "status": 200 } } }
|
||||
*
|
||||
* Other static-hosting platforms (Vercel `vercel.json`, Netlify/Amplify
|
||||
* `_redirects`, nginx `error_page`) express the same idea with different
|
||||
* syntax. The internal `SiteConfig` is intentionally narrow so additional
|
||||
* parsers can normalize to it without churning the consumer in `puterSite.ts` —
|
||||
* see `SITE_CONFIG_FILENAMES` for the lookup list.
|
||||
*
|
||||
* Security posture (every input here originates from a user-uploaded file
|
||||
* served on the open internet):
|
||||
*
|
||||
* - The on-disk file is size-capped before parse (`MAX_CONFIG_BYTES`),
|
||||
* - JSON parsing is wrapped in try/catch — a malformed file silently falls back
|
||||
* to default behavior, never 5xx,
|
||||
* - Every `file` value is normalized as if it were a URL path: it must start with
|
||||
* `/`, gets `pathPosix.normalize`d so `..` is collapsed, and is re-anchored
|
||||
* under the site root before any FS lookup,
|
||||
* - Status codes are clamped to a strict allow-list (the request side only
|
||||
* honours 4xx/5xx error keys; the response side validates the `status` is a
|
||||
* legitimate HTTP integer),
|
||||
* - The config file itself is hidden from public serving by the caller
|
||||
* (`isSiteConfigPath`) — same status/body as any other missing path, no
|
||||
* separate 403 that would leak its existence.
|
||||
*
|
||||
* Loop safety is the consumer's responsibility: when serving an error page, do
|
||||
* NOT re-consult `errors` if the error page itself is missing, otherwise a
|
||||
* misconfigured site could spin a 404→404→404 cycle.
|
||||
*/
|
||||
export interface SiteErrorRule {
|
||||
/** Absolute path under the site root (e.g. `/index.html`). */
|
||||
file: string;
|
||||
@@ -78,39 +38,32 @@ export interface SiteErrorRule {
|
||||
status: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-site config from `.puter_site_config` at the site root, e.g. `{ "errors":
|
||||
* { "404": { "file": "/index.html", "status": 200 } } }`. Every value comes
|
||||
* from a user-uploaded file; the parser validates it before it reaches this
|
||||
* shape. When serving an error page the consumer must not consult `errors`
|
||||
* again if that page is missing, or a misconfigured site loops.
|
||||
*/
|
||||
export interface SiteConfig {
|
||||
/** Map of HTTP status code → custom error rule. Keys are 4xx/5xx. */
|
||||
/** HTTP status code (4xx/5xx) → error rule. */
|
||||
errors: Record<number, SiteErrorRule>;
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
|
||||
// Cache key prefix is distinct from `subdomains:` (SubdomainStore) and
|
||||
// other Redis users — keep this in sync if you rename, otherwise stale
|
||||
// entries from prior deploys could be read back as configs.
|
||||
const CACHE_KEY_PREFIX = 'puter-site-config:';
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
// Sentinel for "we looked, the site has no config" so repeated visits
|
||||
// to a config-less site don't keep round-tripping S3 to confirm.
|
||||
// Cached "site has no config" so config-less sites don't re-read storage.
|
||||
const NEGATIVE_CACHE_MARKER = '__none__';
|
||||
|
||||
/**
|
||||
* Filenames consulted at the site root, in priority order. First file that
|
||||
* parses to a non-empty config wins. Extending this list to add Vercel /
|
||||
* Netlify / nginx adapters is the entrypoint for multi-format support — each
|
||||
* parser receives the raw text and returns a normalized `SiteConfig` (or null
|
||||
* if the file isn't valid in that format).
|
||||
*/
|
||||
/** Filenames consulted at the site root, in priority order. */
|
||||
const SITE_CONFIG_FILENAMES: ReadonlyArray<{
|
||||
name: string;
|
||||
parse: (text: string) => SiteConfig | null;
|
||||
}> = [{ name: '.puter_site_config', parse: parsePuterSiteConfig }];
|
||||
|
||||
/**
|
||||
* Returns true if `urlPath` (already normalized to start with `/`) names the
|
||||
* site config file. Used by `puterSite.ts` to suppress direct serving so the
|
||||
* deployment shape isn't leaked to visitors.
|
||||
*/
|
||||
/** Whether `urlPath` names a config file, which is never served publicly. */
|
||||
export function isSiteConfigPath(urlPath: string): boolean {
|
||||
const base = pathPosix.basename(urlPath);
|
||||
return SITE_CONFIG_FILENAMES.some((f) => f.name === base);
|
||||
@@ -119,12 +72,7 @@ export function isSiteConfigPath(urlPath: string): boolean {
|
||||
interface LoadSiteConfigArgs {
|
||||
/** Absolute site root path (e.g. `/<username>/Public`). */
|
||||
rootPath: string;
|
||||
/**
|
||||
* Stable identifier for the site root, used as the cache key. We key on
|
||||
* `rootDirId` (not the subdomain) so that renaming a subdomain — or
|
||||
* pointing multiple subdomains at the same directory — neither orphans nor
|
||||
* duplicates the cached entry.
|
||||
*/
|
||||
/** Cache key; the directory rather than the subdomain, which can be renamed. */
|
||||
rootDirId: number;
|
||||
fsEntryStore: {
|
||||
getEntryByPath: (path: string) => Promise<FSEntry | null>;
|
||||
@@ -138,21 +86,14 @@ interface LoadSiteConfigArgs {
|
||||
contentLength: number | null;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Optional Redis cache. When omitted, every request re-reads the config
|
||||
* from S3 — fine for tests, slow for prod. Cache failures are swallowed
|
||||
* (best-effort): a transient Redis blip just falls through to the live
|
||||
* read, never errors the request.
|
||||
*/
|
||||
/** Best-effort; cache failures fall through to a live read. */
|
||||
cache?: SiteConfigCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and parse the site config. Returns null when no config file exists,
|
||||
* the file is unreadable, oversized, or fails validation — callers must treat
|
||||
* null as "behave like there is no config" and never raise the error to the
|
||||
* visitor. Errors are logged for the operator but never surfaced to the
|
||||
* request.
|
||||
* Locate and parse the site config. Null when there is none or it is
|
||||
* unreadable, oversized, or invalid; errors are logged, never surfaced to the
|
||||
* visitor.
|
||||
*/
|
||||
export async function loadSiteConfig(
|
||||
args: LoadSiteConfigArgs,
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { withSpan } from '../../../util/span.js';
|
||||
import { HttpError } from '../HttpError.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
FREE_SUBSCRIPTION_IDS,
|
||||
} from '../../../services/metering/consts.js';
|
||||
|
||||
/**
|
||||
* Sliding-window rate limiter with swappable, **co-resident** backends.
|
||||
@@ -534,16 +538,15 @@ function resolveKey(req, scope, strategy) {
|
||||
/**
|
||||
* Bucket identity for an authenticated actor: `<user>[:<app>][:<worker>]`.
|
||||
*
|
||||
* The app segment is the app the actor acts as (`effectiveApp`, so an access
|
||||
* token minted by an app lands in that app's bucket). The worker segment is the
|
||||
* worker's session uid, unique per (user, app, worker name). Without these, a
|
||||
* busy app or worker drains the limit shared by everything else the same user
|
||||
* runs.
|
||||
* The app segment is the app the actor acts as, so an access token minted by an
|
||||
* app lands in that app's bucket. The worker segment is the worker's session
|
||||
* uid, unique per (user, app, worker name). Without these, a busy app or worker
|
||||
* drains the limit shared by everything else the same user runs.
|
||||
*/
|
||||
function actorKey(actor, userId) {
|
||||
const parts = [userId];
|
||||
const app = actor.effectiveApp ?? actor.app;
|
||||
if (app?.uid) parts.push(app.uid);
|
||||
const appUid = actor.effectiveApp?.uid;
|
||||
if (appUid) parts.push(appUid);
|
||||
if (actor.session?.kind === 'worker' && actor.session.uid) {
|
||||
parts.push(actor.session.uid);
|
||||
}
|
||||
@@ -709,6 +712,41 @@ export async function checkRateLimit(key, limit, windowMs, backend) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
* `rateLimitGate` takes one static spec per route, which is wrong for a route
|
||||
* whose cost depends on a request parameter: `stat` with `return_shares` does
|
||||
* the same work as the share-listing route, and should spend from the same
|
||||
* budget. This resolves the key and the per-subscription limit exactly as the
|
||||
* gate does — same spec in, same bucket out — so a handler can charge a second
|
||||
* scope conditionally. Returns true if allowed; fails open on backend error.
|
||||
* Takes the array form too; windows charge in order, no refund on refusal.
|
||||
*/
|
||||
export async function consumeRouteRateLimit(req, spec) {
|
||||
if (Array.isArray(spec)) {
|
||||
for (const window of spec) {
|
||||
if (!(await consumeRouteRateLimit(req, window))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const {
|
||||
window: windowMs,
|
||||
key: strategy = 'fingerprint',
|
||||
scope,
|
||||
backend,
|
||||
} = spec;
|
||||
const backendPair = resolveBackend(backend);
|
||||
const key = resolveKey(req, scope ?? req.route?.path ?? 'route', strategy);
|
||||
try {
|
||||
const limit = await resolveSubscriptionLimit(req, spec);
|
||||
return await backendPair.rate(key, limit, windowMs);
|
||||
} catch (err) {
|
||||
console.error('[rate-limit] handler charge failed, failing open:', err);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read whether `key` still has budget, without spending any. The twin to
|
||||
* `checkRateLimit` for gates whose budget is consumed by an outcome rather than
|
||||
@@ -808,6 +846,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS;
|
||||
* actor, no metering, metering throws) falls through to the base — rate /
|
||||
* concurrency limiting should never _amplify_ a request failure path.
|
||||
*/
|
||||
// An unlisted free plan would otherwise take `limit`, the paid cap.
|
||||
function overrideFor(bySubscription, subscriptionId) {
|
||||
const own = bySubscription[subscriptionId];
|
||||
if (typeof own === 'number') return own;
|
||||
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
|
||||
? bySubscription[DEFAULT_FREE_SUBSCRIPTION]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function resolveSubscriptionLimit(req, opts) {
|
||||
const base = opts.limit;
|
||||
if (!opts.bySubscription || !meteringService) return base;
|
||||
@@ -815,7 +862,7 @@ async function resolveSubscriptionLimit(req, opts) {
|
||||
if (!actor?.user?.uuid) return base;
|
||||
try {
|
||||
const sub = await meteringService.getActorSubscription(actor);
|
||||
const override = opts.bySubscription[sub.id];
|
||||
const override = overrideFor(opts.bySubscription, sub.id);
|
||||
return typeof override === 'number' ? override : base;
|
||||
} catch {
|
||||
return base;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
configureRateLimit,
|
||||
listConfiguredRateLimitBackends,
|
||||
peekRateLimit,
|
||||
@@ -406,6 +407,51 @@ describe('rateLimitGate — bySubscription overrides', () => {
|
||||
expect(await runGate(opts, paidReq)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds a free plan the spec never named to the free cap', async () => {
|
||||
// A team seat resolves to `org_seat_free`; no driver enumerates it, and
|
||||
// falling through to `limit` would outrank an ordinary free account.
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async () => ({ id: 'org_seat_free' }),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 100,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: 'rl-sub-orgseat',
|
||||
};
|
||||
const req = () => ({
|
||||
actor: { user: { id: 7, uuid: 'seat' } },
|
||||
headers: {},
|
||||
});
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(isHttpError(await runGate(opts, req()))).toBe(true);
|
||||
});
|
||||
|
||||
it('still gives a paid plan the base when it names no cap of its own', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async () => ({ id: 'some-paid-tier' }),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 2,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: 'rl-sub-paid-unlisted',
|
||||
};
|
||||
const req = () => ({
|
||||
actor: { user: { id: 8, uuid: 'paid2' } },
|
||||
headers: {},
|
||||
});
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(await runGate(opts, req())).toBeUndefined();
|
||||
expect(isHttpError(await runGate(opts, req()))).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the base `limit` when metering throws', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
@@ -1427,6 +1473,91 @@ describe('checkRateLimit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeRouteRateLimit', () => {
|
||||
beforeEach(() => configureRateLimit());
|
||||
afterEach(() => configureRateLimit());
|
||||
|
||||
const uniqueScope = () => `charge-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
it('spends from the same bucket the route gate does', async () => {
|
||||
// The whole point of the helper: a handler charge and the gate must
|
||||
// resolve the identical key for the same spec + request.
|
||||
const opts = { limit: 2, window: 60_000, key: 'user', scope: uniqueScope() };
|
||||
const req = { actor: { user: { id: 7 } }, headers: {} };
|
||||
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
|
||||
|
||||
const next = vi.fn();
|
||||
await rateLimitGate(opts)(req, {}, next);
|
||||
expect(next.mock.calls[0][0]).toBeUndefined();
|
||||
|
||||
// Gate + helper together exhausted the bucket; either side now rejects.
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(false);
|
||||
const rejectedNext = vi.fn();
|
||||
await rateLimitGate(opts)(req, {}, rejectedNext);
|
||||
expect(isHttpError(rejectedNext.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
it("buckets per user under the 'user' strategy", async () => {
|
||||
const opts = { limit: 1, window: 60_000, key: 'user', scope: uniqueScope() };
|
||||
const reqFor = (id) => ({ actor: { user: { id } }, headers: {} });
|
||||
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(false);
|
||||
expect(await consumeRouteRateLimit(reqFor(2), opts)).toBe(true);
|
||||
});
|
||||
|
||||
it('charges every window of an array spec, and any refusal refuses', async () => {
|
||||
// The array form routes use (burst + sustained); the tightest bites.
|
||||
const specs = [
|
||||
{ limit: 5, window: 60_000, key: 'user', scope: uniqueScope() },
|
||||
{ limit: 2, window: 60_000, key: 'user', scope: uniqueScope() },
|
||||
];
|
||||
const req = { actor: { user: { id: 9 } }, headers: {} };
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(req, specs)).toBe(false);
|
||||
// The refusing window really was the second one, not the first.
|
||||
expect(await consumeRouteRateLimit(req, specs[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('applies bySubscription overrides via the wired metering service', async () => {
|
||||
configureRateLimit({
|
||||
metering: {
|
||||
getActorSubscription: async (actor) => ({
|
||||
id: actor.user.uuid === 'free' ? 'user_free' : 'other',
|
||||
}),
|
||||
},
|
||||
});
|
||||
const opts = {
|
||||
limit: 5,
|
||||
window: 60_000,
|
||||
bySubscription: { user_free: 1 },
|
||||
key: 'user',
|
||||
scope: uniqueScope(),
|
||||
};
|
||||
const freeReq = { actor: { user: { id: 1, uuid: 'free' } }, headers: {} };
|
||||
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(true);
|
||||
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when the backend throws', async () => {
|
||||
configureRateLimit({
|
||||
default: 'redis',
|
||||
redis: {
|
||||
multi: () => {
|
||||
throw new Error('redis exploded');
|
||||
},
|
||||
},
|
||||
});
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const opts = { limit: 1, window: 60_000, key: 'ip', scope: uniqueScope() };
|
||||
const req = { ip: '5.5.5.5', headers: {} };
|
||||
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('peekRateLimit', () => {
|
||||
beforeEach(() => configureRateLimit());
|
||||
afterEach(() => configureRateLimit());
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor } from '../../actor.js';
|
||||
import { TokenService } from '../../../services/auth/TokenService.js';
|
||||
import {
|
||||
createStepUpGate,
|
||||
@@ -187,7 +188,10 @@ describe('createStepUpGate', () => {
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
@@ -201,7 +205,10 @@ describe('createStepUpGate', () => {
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
@@ -212,7 +219,10 @@ describe('createStepUpGate', () => {
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
actor: makeActor({
|
||||
user: { uuid: USER_UUID },
|
||||
app: { uid: 'app-xyz' },
|
||||
}),
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { UserRow } from '../../../stores/user/UserStore';
|
||||
import type { TokenService } from '../../../services/auth/TokenService';
|
||||
import { isAppActor } from '../../actor';
|
||||
import { sessionCookieFlags } from '../../../util/cookieFlags';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
@@ -169,7 +170,12 @@ export function createStepUpGate(deps: {
|
||||
// elevate, so step-up is unsatisfiable for it. Any other actor — most
|
||||
// importantly a root/human session with no app id in its token — falls
|
||||
// through and must present the elevation proof.
|
||||
const appUid = req.actor?.app?.uid;
|
||||
// The app it carries directly, not the one acting: an access token can
|
||||
// never satisfy step-up either, so widening the exemption to the token
|
||||
// chain would only hand it a free pass.
|
||||
const appUid = isAppActor(req.actor)
|
||||
? req.actor?.effectiveApp?.uid
|
||||
: undefined;
|
||||
if (appUid && deps.allowedAppUids?.includes(appUid)) {
|
||||
next();
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, RequestHandler, Response, NextFunction } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { isPlainUserActor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { UserStore, UserRow } from '../../../stores/user/UserStore';
|
||||
@@ -99,12 +100,7 @@ export const createSessionCookieGate = (config: IConfig): RequestHandler => {
|
||||
export const createWebSessionActorGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (
|
||||
!actor?.user ||
|
||||
!actor.session ||
|
||||
(actor as { app?: unknown }).app ||
|
||||
(actor as { accessToken?: unknown }).accessToken
|
||||
) {
|
||||
if (!actor?.user || !actor.session || !isPlainUserActor(actor)) {
|
||||
return next(
|
||||
new HttpError(401, 'Web session required', {
|
||||
legacyCode: 'session_required',
|
||||
|
||||
+110
-242
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { Actor } from '../actor';
|
||||
|
||||
/**
|
||||
@@ -26,20 +27,9 @@ import type { Actor } from '../actor';
|
||||
* it instead of accepting any token that authenticates.
|
||||
*/
|
||||
export type TokenSource =
|
||||
| 'body'
|
||||
| 'header'
|
||||
| 'x-api-key'
|
||||
| 'cookie'
|
||||
| 'query'
|
||||
| 'handshake';
|
||||
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
|
||||
|
||||
/**
|
||||
* Every route method PuterRouter exposes. Mirrors the express router surface
|
||||
* plus WebDAV verbs that some endpoints still use.
|
||||
*
|
||||
* `use` and `all` don't map to a single HTTP verb — they're treated uniformly
|
||||
* by the materializer (see `v2/server.ts`).
|
||||
*/
|
||||
/** Express router methods plus the WebDAV verbs some endpoints use. */
|
||||
export type RouteMethod =
|
||||
| 'use'
|
||||
| 'all'
|
||||
@@ -59,37 +49,36 @@ export type RouteMethod =
|
||||
| 'move';
|
||||
|
||||
/**
|
||||
* Path shape accepted by express route methods. Kept permissive rather than
|
||||
* re-exporting express's internal `PathParams` (which isn't stable public
|
||||
* API).
|
||||
* Express route path; kept permissive since express's `PathParams` is not
|
||||
* public API.
|
||||
*/
|
||||
export type RoutePath = string | RegExp | Array<string | RegExp>;
|
||||
|
||||
/**
|
||||
* Per-route options declared by the caller.
|
||||
*
|
||||
* The materializer (`v2/server.ts#materializeRoute`) translates these into a
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth (+ suspended) → emailConfirmed →
|
||||
* requireUserActor → adminOnly → allowedAppIds → phoneVerified →
|
||||
* cardVerified → requireReputation → requireSubscription → rateLimit →
|
||||
* requireCredits → concurrent → caller `middleware: []` → handler
|
||||
*
|
||||
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`;
|
||||
* the materializer dedupes so only one auth gate ends up in the chain.
|
||||
* Commented-out slots are reserved for the next chunks (body parsing, post-auth
|
||||
* gates, timing).
|
||||
*/
|
||||
/** The account-verification factors a route can ask for by name. */
|
||||
export type VerificationFactor = 'phone' | 'card';
|
||||
|
||||
/** The user fields the account gates read; all optional, like `Actor.user`. */
|
||||
export type AccountGateUser = Partial<
|
||||
Pick<
|
||||
UserRow,
|
||||
| 'suspended'
|
||||
| 'email_confirmed'
|
||||
| 'requires_email_confirmation'
|
||||
| 'requires_phone_verification'
|
||||
| 'requires_card_verification'
|
||||
| 'requires_password_change'
|
||||
| 'phone'
|
||||
| 'card_fingerprint'
|
||||
>
|
||||
>;
|
||||
|
||||
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
|
||||
export interface RouteRateLimit {
|
||||
limit: number;
|
||||
window: number;
|
||||
/**
|
||||
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id` → cap).
|
||||
* Same mechanic as `concurrent.bySubscription`: resolved via the metering
|
||||
* service per request; falls back to the base `limit` when there's no actor
|
||||
* / no metering / no match.
|
||||
* Per-`SubscriptionPolicy.id` overrides for `limit`; the base applies when
|
||||
* there is no match.
|
||||
*/
|
||||
bySubscription?: Record<string, number>;
|
||||
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
|
||||
@@ -97,241 +86,148 @@ export interface RouteRateLimit {
|
||||
backend?: 'memory' | 'redis' | 'kv';
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route options. The materializer (`v2/server.ts`) turns them into a
|
||||
* middleware chain in this order:
|
||||
*
|
||||
* subdomain → requireAuth → emailConfirmed → requireUserActor → adminOnly →
|
||||
* allowedAppIds → phoneVerified → cardVerified → anyVerified →
|
||||
* requireReputation → requireSubscription → rateLimit → requireCredits →
|
||||
* concurrent → `middleware` → handler
|
||||
*
|
||||
* Options that imply `requireAuth` are deduped to a single auth gate.
|
||||
*/
|
||||
export interface RouteOptions {
|
||||
/**
|
||||
* Extra per-route middleware. Applied after built-in gates, before the
|
||||
* handler.
|
||||
*/
|
||||
/** Extra middleware, run after the built-in gates. */
|
||||
middleware?: RequestHandler[];
|
||||
|
||||
/**
|
||||
* Subdomain routing. If set, the route only matches requests whose leftmost
|
||||
* subdomain is in this list (via `next('route')` skip).
|
||||
*
|
||||
* If omitted, verb-routes (get/post/etc.) are restricted to the root origin
|
||||
* only (no subdomain). Pass `'*'` to explicitly match ANY subdomain/root.
|
||||
* `use()` middleware is not gated by default.
|
||||
* Only match requests whose leftmost subdomain is listed. Verb routes
|
||||
* default to the root origin only; `'*'` matches any. `use()` middleware is
|
||||
* not gated by default.
|
||||
*/
|
||||
subdomain?: string | string[];
|
||||
|
||||
/**
|
||||
* Reject anonymous + suspended-user requests with 401/403. Only allows user
|
||||
* and app actors
|
||||
*/
|
||||
/** 401 for anonymous requests, 403 for suspended accounts. */
|
||||
requireAuth?: boolean;
|
||||
|
||||
/** Reject app/access-token actors. Implies `requireAuth`. */
|
||||
requireUserActor?: boolean;
|
||||
|
||||
/**
|
||||
* Only meaningful alongside `requireUserActor`. Relaxes the access-token
|
||||
* half of that gate so a FULL-ACCESS personal access token (the user's own
|
||||
* credential) is admitted — third-party apps and scoped tokens stay
|
||||
* blocked. Use ONLY on user-resource / inference endpoints that gate with
|
||||
* `requireUserActor` purely to keep apps out (e.g. the AI proxy). NEVER set
|
||||
* on account/security/credential routes — those must stay closed to every
|
||||
* access token. Default-deny: omitting this keeps PATs blocked.
|
||||
* With `requireUserActor`, admit full-access personal access tokens; apps
|
||||
* and scoped tokens stay blocked. Never set on account or security routes.
|
||||
*/
|
||||
allowFullAccessToken?: boolean;
|
||||
|
||||
/** Allows access-tokens */
|
||||
/** Admit scoped access tokens. */
|
||||
allowAccessToken?: boolean;
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors ("root" tokens — no app, no access token)
|
||||
* with 403 `app_or_api_token_required`. Implies `requireAuth`. Use on API
|
||||
* surfaces that must only be driven by a delegated credential (app/worker
|
||||
* token or a dashboard-minted API token), so a copied session token can't
|
||||
* double as an API credential. Compose with `requireUserActor` +
|
||||
* `allowFullAccessToken` to further narrow to API tokens only (apps stay
|
||||
* out too). Worker sessions (`session.kind === 'worker'`) always pass — a
|
||||
* deployed worker is a delegated, revocable credential, never a root
|
||||
* token.
|
||||
* Reject bare account-session actors with 403 `app_or_api_token_required`;
|
||||
* worker sessions pass. Implies `requireAuth`.
|
||||
*/
|
||||
noUserSession?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's username is `admin`, `system`, or one of the
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
* to that pair (does not replace it). Implies `requireAuth`.
|
||||
*
|
||||
* Also requires a _root token_ (an actor with no app anywhere in its token
|
||||
* chain), so an admin acting through a third-party app can't reach the
|
||||
* route. Pair with `allowedAppIds` to make an admin route reachable by
|
||||
* specific apps: the combination admits a root token OR a token scoped to
|
||||
* an allowed app.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — a root token still includes an
|
||||
* admin's full-access personal access token, not only browser sessions.
|
||||
* Combine with `requireUserActor` to restrict to browser sessions.
|
||||
* Reject unless the username is `admin`, `system`, or one of the listed
|
||||
* extras, and the actor is a root token (no app in its chain). Pair with
|
||||
* `allowedAppIds` to also admit tokens scoped to those apps. Implies
|
||||
* `requireAuth`, not `requireUserActor`.
|
||||
*/
|
||||
adminOnly?: boolean | string[];
|
||||
|
||||
/**
|
||||
* Reject an app-under-user actor whose app is not in this list. Implies
|
||||
* Reject actors acting as an app that is not listed, whether carried
|
||||
* directly or through the app that issued their access token. Actors with
|
||||
* no app in the chain pass; see `allowedAppIdsGate`. Implies
|
||||
* `requireAuth`.
|
||||
*
|
||||
* Not an app gate: actors carrying no app of their own — browser sessions,
|
||||
* full-access personal access tokens, workers, and access tokens issued by
|
||||
* an app — pass through untouched. `adminOnly` + `allowedAppIds` relies on
|
||||
* that. See `allowedAppIdsGate` before using this to scope a route to
|
||||
* apps.
|
||||
*/
|
||||
allowedAppIds?: string[];
|
||||
|
||||
/**
|
||||
* Allow users whose account is pending email confirmation to access this
|
||||
* route. By default, any authenticated route rejects users where
|
||||
* `requires_email_confirmation && !email_confirmed` with 403. Set this to
|
||||
* `true` on essential flows that must remain accessible before
|
||||
* confirmation: logout, email-confirm, whoami, save-account, anti-CSRF
|
||||
* token, etc.
|
||||
*
|
||||
* Only meaningful when the route also requires authentication (via
|
||||
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
|
||||
* Let accounts still pending a verification through. For the flows that
|
||||
* clear the pending state (logout, confirm-email, whoami, save-account).
|
||||
*/
|
||||
allowUnconfirmed?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's user has a confirmed email. 400 with
|
||||
* `account_is_not_verified` on failure. No-op when
|
||||
* `config.strict_email_verification_required` is falsy, so self-hosted
|
||||
* deployments can opt in via config. Implies `requireAuth` but NOT
|
||||
* `requireUserActor` — app-under-user actors also carry a `.user`, so
|
||||
* verification applies uniformly whether the user acts directly or through
|
||||
* an app.
|
||||
* Reject unless the email is confirmed. Inert unless
|
||||
* `config.strict_email_verification_required`. Implies `requireAuth`.
|
||||
*/
|
||||
requireVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the user has a phone number verified on file (verified at
|
||||
* some point, not merely never asked to). 403
|
||||
* `phone_verification_required`. Implies `requireAuth`; independent of the
|
||||
* default-on pending-verification gate, which only turns away accounts the
|
||||
* abuse harness flagged.
|
||||
*
|
||||
* Opt-in per route, for surfaces worth the friction of an SMS round trip.
|
||||
* 403 `phone_verification_required` unless a phone was verified. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requirePhoneVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the user has a card verified on file. 403
|
||||
* `card_verification_required`. Same shape as `requirePhoneVerified` — the
|
||||
* opt-in, "must have actually done it" counterpart of the pending gate.
|
||||
* 403 `card_verification_required` unless a card was verified. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requireCardVerified?: boolean;
|
||||
|
||||
/**
|
||||
* Per-route JSON body parsing override. By default the global parser
|
||||
* handles every `application/json` request with a 50mb limit and stashes
|
||||
* the raw bytes on `req.rawBody` for signature-verification use cases.
|
||||
*
|
||||
* Use this option only when a route needs different parser settings:
|
||||
*
|
||||
* - `false` — opt out of parsing entirely (rare; the route reads the raw
|
||||
* stream itself, e.g. some webhook proxies). The global parser will still
|
||||
* have already run if the content-type was JSON, so this is mostly useful
|
||||
* for routes that accept _non_-JSON body shapes and want to ensure no
|
||||
* further parsers attach.
|
||||
* - `{ limit, type }` — override the limit (e.g., for ML endpoints that
|
||||
* legitimately need 100mb) or the matched content-type list (e.g., to
|
||||
* ALSO accept `application/x-ndjson`).
|
||||
* Reject unless one of the listed factors is verified; a paid plan counts
|
||||
* for `card`. Only factors this deployment can verify are asked for, so
|
||||
* with none verifiable the gate is inert. The 403 lists the verifiable
|
||||
* factors in `factors`. An empty list is a boot error.
|
||||
*/
|
||||
requireAnyVerified?: readonly VerificationFactor[];
|
||||
|
||||
/**
|
||||
* Override the global JSON parser: `false` opts out, `{ limit, type }`
|
||||
* changes the size limit or the matched content types.
|
||||
*/
|
||||
bodyJson?: false | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route raw (Buffer) body parser. Use for binary uploads where the
|
||||
* route handler wants `req.body: Buffer` directly. Default content-type
|
||||
* match is `application/octet-stream`; pass `type` to override.
|
||||
* Buffer body parser. Matches `application/octet-stream` unless `type`
|
||||
* overrides.
|
||||
*/
|
||||
bodyRaw?: boolean | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route text body parser. `req.body` becomes a string. Default
|
||||
* content-type match is `text/plain`.
|
||||
*/
|
||||
/** Text body parser. Matches `text/plain` by default. */
|
||||
bodyText?: boolean | { limit?: string; type?: string | string[] };
|
||||
|
||||
/**
|
||||
* Per-route urlencoded form parser. `req.body` becomes a parsed object.
|
||||
* Default `extended: true` (uses `qs`); pass `extended: false` for the
|
||||
* built-in `querystring` parser.
|
||||
*/
|
||||
/** Form parser. `extended: true` (default) uses `qs`. */
|
||||
bodyUrlencoded?: boolean | { limit?: string; extended?: boolean };
|
||||
|
||||
/**
|
||||
* Require captcha verification. When `true`, the route rejects requests
|
||||
* that don't carry valid `captchaToken` + `captchaAnswer` fields in the
|
||||
* body. No-op when captcha is disabled in config.
|
||||
* Require valid `captchaToken` + `captchaAnswer` in the body. No-op when
|
||||
* captcha is disabled.
|
||||
*/
|
||||
captcha?: boolean;
|
||||
|
||||
/**
|
||||
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`. The
|
||||
* token is consumed on use. Requires authentication (keyed by user uuid).
|
||||
* Require a valid one-time `anti_csrf` body token. Needs an authenticated
|
||||
* user.
|
||||
*/
|
||||
antiCsrf?: boolean;
|
||||
|
||||
/**
|
||||
* Restrict the route to pages on this deployment's own GUI origin
|
||||
* (`config.origin`, plus any `config.allow_gui_origins`). Requests with no
|
||||
* `Origin` header still pass — the gate stops cross-origin browser pages,
|
||||
* not non-browser clients.
|
||||
*
|
||||
* For routes that return a session credential to the caller. See
|
||||
* `guiOriginGate` for why reflected-CORS makes this necessary.
|
||||
* Only allow pages on this deployment's GUI origin; requests with no
|
||||
* `Origin` header still pass. For routes returning a session credential;
|
||||
* see `guiOriginGate`.
|
||||
*/
|
||||
guiOriginOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Per-route rate limiting. In-memory sliding window keyed by request
|
||||
* identity.
|
||||
*
|
||||
* `key` controls how requests are bucketed:
|
||||
*
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers), refined by the
|
||||
* client's device fingerprint when one was supplied. Safe for shared IPs
|
||||
* (offices, VPNs): each device gets its own bucket instead of the whole
|
||||
* network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes where you want
|
||||
* per-account limits.
|
||||
* - `(req) => string` — custom key function.
|
||||
*
|
||||
* `scope` is an optional namespace prefix to isolate counters between
|
||||
* routes that share the same key strategy. Defaults to the route path.
|
||||
*
|
||||
* `backend` selects the storage backend ('memory' / 'redis' / 'kv'). All
|
||||
* registered backends stay co-resident at runtime, so different routes can
|
||||
* pick whichever fits their access pattern. Omitting `backend` uses the
|
||||
* server-wide default (`config.rate_limit.backend`).
|
||||
*
|
||||
* An array applies every limit independently (a request must pass all of
|
||||
* them). Use this to pair a per-client budget with a coarser backstop on a
|
||||
* different key — e.g. per-fingerprint for fairness on shared IPs, plus
|
||||
* per-IP so rotating headers can't mint fresh fingerprint buckets
|
||||
* indefinitely. Give each entry its own `scope` so the counters don't
|
||||
* collide.
|
||||
* Sliding-window rate limit. `key` picks the bucket: `fingerprint`
|
||||
* (default; network hash refined by device fingerprint), `ip`, `user`, or a
|
||||
* function. `scope` namespaces the counter (defaults to the route path);
|
||||
* `backend` defaults to `config.rate_limit.backend`. An array applies every
|
||||
* limit independently; give each its own `scope`.
|
||||
*/
|
||||
rateLimit?: RouteRateLimit | RouteRateLimit[];
|
||||
|
||||
/**
|
||||
* Concurrent in-flight limiting. Caps how many requests for this key are
|
||||
* simultaneously in flight, rather than how many fire per window. A slot is
|
||||
* acquired before the handler runs and released on `res.finish` /
|
||||
* `res.close` — aborted requests still give their slot back.
|
||||
*
|
||||
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
|
||||
* bySubscription: { user_free: 2 } } }
|
||||
*
|
||||
* `bySubscription` overrides the base `limit` per subscription tier
|
||||
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited` out of
|
||||
* the box. Requires the metering service to be wired into the rate-limit
|
||||
* module (`configureRateLimit({ metering: ... })`) and an authenticated
|
||||
* actor; otherwise the base `limit` applies.
|
||||
*
|
||||
* `key`, `scope`, `backend` parallel the `rateLimit` option exactly;
|
||||
* there's no `window` — that's the whole point of the second gate.
|
||||
* Cap simultaneous in-flight requests per key; slots are released on
|
||||
* response finish or close. `bySubscription` overrides `limit` per
|
||||
* `SubscriptionPolicy.id` when metering is wired in. `key`, `scope`,
|
||||
* `backend` as in `rateLimit`.
|
||||
*/
|
||||
concurrent?: {
|
||||
limit: number;
|
||||
@@ -342,55 +238,23 @@ export interface RouteOptions {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject an account with nothing left of its usage budget with 402
|
||||
* `insufficient_funds`.
|
||||
*
|
||||
* For routes that spend metered resources on the caller's behalf — moving
|
||||
* file content, making object-store requests. Not for the routes that show
|
||||
* an account what it has or let it delete things: an account that has run
|
||||
* out still needs to be able to see its files, clear space, and reach its
|
||||
* billing pages, and blocking that leaves no way out other than paying.
|
||||
*
|
||||
* Anonymous callers and worker sessions pass; see `requireCreditsGate`.
|
||||
* Rate limits remain the bound on request _count_ — this bounds spend, and
|
||||
* lags the traffic that produced it by the metering buffer window, so it
|
||||
* stops sustained usage rather than a burst.
|
||||
* 402 `insufficient_funds` when the account has no usage budget left. For
|
||||
* routes that spend metered resources, not for the ones that let an account
|
||||
* see or free up what it has. Anonymous callers and worker sessions pass.
|
||||
*/
|
||||
requireCredits?: boolean;
|
||||
|
||||
/**
|
||||
* Reject a caller whose plan doesn't include this route with 402
|
||||
* `subscription_required`.
|
||||
*
|
||||
* `true` accepts any plan that isn't one of the free ones, so a plan
|
||||
* registered by an extension counts without being named here. An array of
|
||||
* `SubscriptionPolicy.id`s accepts only those, for a feature that belongs
|
||||
* to specific plans (`requireSubscription: ['business', 'pro']`).
|
||||
*
|
||||
* Opt-in: nothing is subscriber-only unless it says so. Implies
|
||||
* `requireAuth` — there is no plan to read off an anonymous caller. The
|
||||
* answer comes from the metering service's per-actor subscription cache, so
|
||||
* the check costs a map lookup on a warm actor. Distinct from
|
||||
* `requireCredits`, which asks whether an account has budget left rather
|
||||
* than which plan it is on; a route may want both.
|
||||
* 402 `subscription_required` unless the plan qualifies: `true` accepts any
|
||||
* non-free plan, an array of `SubscriptionPolicy.id`s only those. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
requireSubscription?: boolean | string[];
|
||||
|
||||
/**
|
||||
* Reject a caller whose account isn't trusted enough for this route with
|
||||
* 403 `reputation_required`.
|
||||
*
|
||||
* The value names a tier; the minimum score that tier takes is deployment
|
||||
* config (`reputationGate.tiers`), so the number a surface is worth stays
|
||||
* out of the surface's declaration and can be retuned without editing it. A
|
||||
* tier the running config doesn't define is inert and everyone passes — a
|
||||
* deployment that doesn't score its accounts turns nobody away.
|
||||
*
|
||||
* Opt-in, and implies `requireAuth`: there is no account to have earned
|
||||
* anything on an anonymous caller. The declaration decides the chain, so a
|
||||
* route asking for a tier is authenticated whether or not the tier
|
||||
* currently enforces anything. `false` is "no requirement", the same as
|
||||
* leaving it out.
|
||||
* 403 `reputation_required` unless the account meets the named tier.
|
||||
* Thresholds come from `reputationGate.tiers`; an undefined tier is inert.
|
||||
* Implies `requireAuth`.
|
||||
*/
|
||||
requireReputation?: string | false;
|
||||
|
||||
@@ -464,15 +328,19 @@ export type AuthRequired<O extends RouteOptions> = O extends {
|
||||
: O extends { requireCardVerified: true }
|
||||
? true
|
||||
: O extends {
|
||||
requireSubscription:
|
||||
| true
|
||||
| readonly string[]
|
||||
| string[];
|
||||
requireAnyVerified:
|
||||
| readonly VerificationFactor[]
|
||||
| VerificationFactor[];
|
||||
}
|
||||
? true
|
||||
: O extends { requireReputation: string }
|
||||
: O extends {
|
||||
requireSubscription:
|
||||
true | readonly string[] | string[];
|
||||
}
|
||||
? true
|
||||
: false;
|
||||
: O extends { requireReputation: string }
|
||||
? true
|
||||
: false;
|
||||
|
||||
/** Express `Request` with `actor` narrowed based on the route's options. */
|
||||
export type TypedRequest<O extends RouteOptions> = Omit<Request, 'actor'> & {
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
isSystemActor,
|
||||
isAppActor,
|
||||
isAccessTokenActor,
|
||||
isPlainUserActor,
|
||||
actorUid,
|
||||
assertResolvedActor,
|
||||
makeActor,
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ORG_SEAT_FREE } from './orgSeatFreePolicy.js';
|
||||
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
|
||||
import { TEMP_USER_FREE } from './tempUserFreePolicy.js';
|
||||
|
||||
export const SUB_POLICIES = [TEMP_USER_FREE, REGISTERED_USER_FREE];
|
||||
export const SUB_POLICIES = [
|
||||
TEMP_USER_FREE,
|
||||
REGISTERED_USER_FREE,
|
||||
ORG_SEAT_FREE,
|
||||
];
|
||||
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user