Compare commits

..
95 Commits
Author SHA1 Message Date
Daniel Salazar 0ff0ea7743 fix: fs event emissions (#3153) 2026-05-25 14:25:27 -07:00
Daniel Salazar 8be115096f feat (put-1012 put-1014): new tokens version signature and logic (#3152) 2026-05-25 14:19:23 -07:00
Daniel Salazar e66fd2373f feat: start adding support for token expiry and invalidation (#3151)
* feat: new expirable + revokable session

* fix: msql migration
2026-05-25 12:30:20 -07:00
jelveh 0e2cce152d Update PuterAlert.js 2026-05-24 17:03:34 -07:00
Daniel Salazar 101e0bc930 fix: search leaking metadata (#3150) 2026-05-24 16:21:33 -07:00
jelveh ea078250bf Strip non-essential media from docs' markdown 2026-05-24 16:15:24 -07:00
jelveh db79f8e484 Add note: serve Puter.js via HTTP server 2026-05-24 15:48:21 -07:00
Daniel Salazar 6422f2d513 fix: associated app ids as input (#3149) 2026-05-23 13:01:03 -07:00
ProgrammerIn-wonderland c60626f772 Add preamble versioning (#3147)
* Add preamble versioning

* update migrations index
2026-05-23 01:15:58 -04:00
ProgrammerIn-wonderland cafdaf7b50 fix bug with signed url directory listings (#3146) 2026-05-22 18:48:41 -04:00
Daniel Salazar bb0f56d758 fix: bring back puter site config logic (#3145) 2026-05-22 11:15:45 -07:00
NighttandReynaldi Chernando 7438db7a90 Add OpenRouter model release dates (#3144)
* Add OpenRouter model release dates

* simplify implementation

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
2026-05-22 12:58:59 -04:00
Daniel Salazar 948f771838 feat: ai limits tightning and add severity alert (#3142) 2026-05-21 20:46:19 -07:00
Nariman Jelveh 82e34f014d Warn on file:// loads using PuterDialog 2026-05-21 19:15:21 -07:00
Nariman Jelveh 9e4c3f8ba3 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-05-21 14:39:44 -07:00
Nariman JelvehandClaude Opus 4.7 e6acd7386a Bump @heyputer/puter.js to 2.4.2
Record the 2.4.2 release in the repo. The version was published to npm
(via prepublishOnly's `npm version patch`) but the bump was never
committed, leaving package.json stuck at 2.4.1 on GitHub.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-21 14:39:18 -07:00
Daniel Salazar b04e8b046a fix: fixing misc issues around alerting and bugs (#3141)
- decrease ai related alerts and make errors not 500
- decreased bad fs controller checks to be 404s instead of 500s
- force bucket regions for now
- validate webdav perms for locking/unlocking
2026-05-21 14:26:47 -07:00
Nariman Jelveh 3262b984b7 Unify Email/Usage dialogs with Puter dialog style 2026-05-21 14:26:26 -07:00
Nariman Jelveh 71af88c73c Bump @heyputer/puter.js to 2.4.1 2026-05-21 14:06:48 -07:00
Nariman Jelveh 2275f65169 Centralize auth popup handling and consent dialog 2026-05-21 12:06:47 -07:00
ProgrammerIn-wonderland 71b7d6367f fix together provider issues (#3140) 2026-05-21 08:58:48 -04:00
Nariman Jelveh 08369d840b Add cssstyle to docs dependencies (#3139) 2026-05-20 17:41:02 -07:00
Daniel Salazar 94236133b5 fix: event types (#3138)
* fix: agents.md

* fix weird names

* fix: weird names again

* fix: event types

* cleanup types
2026-05-20 16:26:16 -07:00
Daniel Salazar 84b48541bd fix: event types + emit subdomain serve event (#3137)
* fix: agents.md

* fix weird names

* fix: weird names again
2026-05-20 12:47:56 -07:00
Daniel Salazar a33d02f522 fix: agents.md name (#3136) 2026-05-20 12:18:35 -07:00
Reynaldi Chernando f94d4aa7d9 Update gemini 3.5 flash release date (#3135) 2026-05-20 13:25:46 -04:00
Daniel Salazar 89cfb0c634 fix: subdomain htmls (#3134) 2026-05-19 22:04:57 -07:00
ProgrammerIn-wonderland c9096e9eed more validation for signed URLs (#3133) 2026-05-19 19:04:42 -04:00
Daniel Salazar 859ca207c5 feat: new gemini 3.5 flash model (#3131) 2026-05-19 15:16:07 -07:00
Daniel Salazar 58fc122f0d fix: app creation dupped names limit (#3130)
* fix: app creation dupped names limit

* fix: app dupped names
2026-05-19 12:18:59 -07:00
ProgrammerIn-wonderland 0e990da961 import ESM by URL instead of path (#3129) 2026-05-18 19:31:03 -04:00
db8236cc97 update Indonesian section (Indonesian language) (#3117)
* Update id.js

* Update id.js

* Update id.js

* Update id.js

* Update README.id.md

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* refine indonesia i18n

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Reynaldi Chernando <[email protected]>
2026-05-18 17:15:01 +07:00
jelveh d731ccf873 Bump puter.js version to 2.3.4 2026-05-17 23:20:41 -07:00
Daniel Salazar 6493d66901 fix: misc write issues with fs + openrouter refresh (#3125)
* feat: refresh openrouter models every 15 min

* fix: if no extensions, skip build step

* fix: misc write issues with fs
2026-05-17 12:34:08 -07:00
Daniel Salazar 9e7f38f473 fix: validate private apps token too (#3124) 2026-05-17 12:28:32 -07:00
Daniel Salazar a568ca0f99 fix: expose upstream ai errors as not 500s (#3123) 2026-05-17 12:01:22 -07:00
Nariman Jelveh 41e7228a9f Update PuterDialog.js 2026-05-16 17:01:40 -07:00
Nariman Jelveh 4568639718 Adjust PuterDialog footer and button spacing 2026-05-16 16:57:35 -07:00
Nariman Jelveh 73ecff0bd7 Tweak PuterDialog spacing and button widths 2026-05-16 16:50:31 -07:00
Nariman Jelveh f2f023880f Remove mobile column-reverse for dialog buttons 2026-05-16 16:42:27 -07:00
Nariman Jelveh f2093e9204 PuterDialog: UI/style tweaks and button reorder 2026-05-16 16:36:53 -07:00
Nariman Jelveh 23497dd371 Add dark mode for puter.js login prompt 2026-05-16 16:04:29 -07:00
Nariman Jelveh 37619fb2a3 Remove embedded background image from PuterDialog 2026-05-16 15:43:15 -07:00
Nariman Jelveh f976686eba Update index.md 2026-05-16 13:12:31 -07:00
ProgrammerIn-wonderland 7af3721543 Make confirming an email unconfirm any other accounts using that email (#3121) 2026-05-16 15:07:38 -04:00
ProgrammerIn-wonderland 93edec0d01 assert normalized (#3119) 2026-05-15 17:01:32 -04:00
Daniel Salazar 54b61f2e24 fix: bad cookie parse test (#3120)
* fix: deepseek alias

* fix: bad cookie parse test
2026-05-15 13:40:43 -07:00
Nariman Jelveh 184ed77493 Use fixed colors for OIDC login divider 2026-05-14 20:26:46 -07:00
Nariman Jelveh 8234b72fc3 Improve login UI: logo, forgot link, OIDC divider 2026-05-14 20:20:21 -07:00
Nariman Jelveh cc9921945e Make auth logo clickable for cover pages 2026-05-14 20:13:33 -07:00
Daniel Salazar ac6f23a41f Update README.md (#3116) 2026-05-14 13:01:34 -07:00
Nariman Jelveh 973d046d33 Remove legacy TabFiles component
Delete the large TabFiles.js file (file browser tab) and update dashboard, styles, helpers, and init logic accordingly. Related changes in UIDashboard.js, dashboard.css, style.css, helpers.js and initgui.js remove or adapt references to the removed component and adjust UI/initialization and styling to account for the file-tab extraction. This reduces duplicate/legacy code and centralizes file browser functionality; review UIDashboard and helper updates to ensure file management features (navigation, upload, context menus, previews) are migrated or re-implemented as needed.
2026-05-14 12:13:48 -07:00
Miika Kuisma dcb50cde8f Merge pull request #3115 from HeyPuter/puter-js-update260514-2
PuterJS - Fix submenu behavior on mobile devices in Menubar web component
2026-05-14 11:45:47 +03:00
Miika Kuisma 08beb55853 PuterJS - Fix submenu behavior on mobile devices in Menubar web component 2026-05-14 11:43:47 +03:00
Miika Kuisma bb0220e163 Merge pull request #3114 from HeyPuter/puter-js-update-260514
Puter JS MenuBar Web Component improvements
2026-05-14 09:14:19 +03:00
Miika Kuisma f3e4c8c6d0 menu item focus tweak 2026-05-14 09:09:40 +03:00
Miika Kuisma 44254671bb - When navigating with keyboard the main level of menubar (file, edit etc) is should also open the menus, now it just highlights the opener
- When hovering main menu items, it must clear hover effects
- Two submenus under each other and diagonal moving
- separator hovering removes highlight but disabled item should do the same
- better way to remove focus
- when mouse leaves context menu highlight should be removed
2026-05-14 08:26:36 +03:00
Daniel Salazar bcc6cc8f00 fix: express proxy chain (#3112) 2026-05-13 18:13:39 -07:00
jelveh 84549a1c31 Add Authorization header to deletion request 2026-05-13 17:21:40 -07:00
Daniel Salazar 0276420213 fix: treat access tokens as non-auth unless opted in (#3111)
* fix: treat access tokens as non-auth unless opted in

* fix: tests
2026-05-13 14:20:15 -07:00
ProgrammerIn-wonderland 6353a08fd5 accept web input in speech2txt (#3110) 2026-05-13 15:53:16 -04:00
Daniel Salazar c265fa308a fix: double rate limits across the board (#3108)
* fix: sqlite auto create path if missing

* fix: double rate limits across the board
2026-05-13 09:30:08 -07:00
Miika Kuisma d3f72bc33a Merge pull request #3105 from HeyPuter/puter-js-menubar-fixes
Puter JS UI Component updates
2026-05-13 11:51:42 +03:00
ProgrammerIn-wonderland 700fc4d27e alibaba provider (#3103)
* Add alibaba provider

* add tests
2026-05-13 03:21:15 -04:00
Miika Kuisma 9c7f30a150 Add theme attribute for each web component, which can be used for forcing dark or light theme (in case the application doesn't have dark mode support it might be odd if just menubar turns dark) 2026-05-13 10:08:59 +03:00
Miika Kuisma 85e45b30f5 fixes https://github.com/HeyPuter/puter/issues/3102 2026-05-13 09:52:21 +03:00
Miika Kuisma 5483e32eab fixes https://github.com/HeyPuter/puter/issues/3101 2026-05-13 09:37:36 +03:00
Miika Kuisma f63c7d8142 - Fixes https://github.com/HeyPuter/puter/issues/3100
- Also fixes similar issues with navigating top level menubar items and submenus
2026-05-13 09:27:44 +03:00
Miika Kuisma 5a2071f852 fix https://github.com/HeyPuter/puter/issues/3099 2026-05-13 09:01:57 +03:00
Miika Kuisma 238ed12e28 - add dark mode support for UI components
- fix https://github.com/HeyPuter/puter/issues/3098
2026-05-13 08:54:23 +03:00
Daniel Salazar d1afe6c3cf fix: puter site redirect for non paid apps (#3104)
* fix: puter site redirect for non paid apps

* add tests
2026-05-12 21:03:33 -07:00
Nariman Jelveh e58dd48e80 Fix link formatting in BUG-BOUNTY.md (#3097) 2026-05-12 17:19:10 -07:00
Daniel Salazar 69f87d4dcd fix: return code (#3096)
* fix: return code

* fix: http error
2026-05-12 16:54:00 -07:00
Daniel Salazar 7b0dd11f7f fix: dev accounts not showing (#3095) 2026-05-12 16:39:42 -07:00
Daniel Salazar 620934fbf4 fix: add user_agent to email signup (#3094) 2026-05-12 15:51:30 -07:00
Daniel Salazar 182fc723f1 expose hasDevAccountAccess at top level (#3093) 2026-05-12 13:56:00 -07:00
Daniel Salazar 5d3c5b6873 fix: whoami (#3092) 2026-05-12 13:44:02 -07:00
slashdevcorpseandCodex f20445bab7 Restrict cross-origin session-cookie auth (#3088)
* Restrict cross-origin cookie auth

Block the auth probe from using ambient session cookies when a browser Origin does not match the request Host. Explicit bearer, body, and x-api-key tokens continue to work for cross-origin SDK calls.

Co-authored-by: Codex <[email protected]>

* Normalize origin checks for cookie auth

Compare normalized origins for browser cookie authentication so default ports and protocol mismatches are handled consistently. Add coverage for default-port and protocol-mismatch cases.

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
2026-05-12 13:40:25 -07:00
Nariman Jelveh 8ad1a58f98 Reorder OIDC Apple and Microsoft buttons 2026-05-12 13:35:18 -07:00
Daniel Salazar 17e475236a fix: mistral ai tests from renames (#3091) 2026-05-12 12:28:32 -07:00
Daniel Salazar b948eaaaa6 chore: update mistralAi providers (#3090) 2026-05-12 12:12:24 -07:00
Nariman Jelveh 9b0c034247 fix: correct xAI TTS pricing to $15.00/1M characters (#3089)
xAI's Voice pricing table lists Text to Speech at $15.00 / 1M characters,
but we were charging $4.20 / 1M characters (420 microcents/char), undercharging
by ~72%. Update the cost constant, listEngines pricing, and test expectation
to 1500 microcents/char.
2026-05-12 11:57:11 -07:00
Miika Kuisma b0dc9c7f1b Merge pull request #3086 from HeyPuter/puter-js-update-260512
PuterJS menubar web component improvements
2026-05-12 12:01:08 +03:00
Miika Kuisma e79abd43e3 PuterJS menubar web component improvements 2026-05-12 12:00:15 +03:00
Miika Kuisma 36c9c478ec Merge pull request #3073 from HeyPuter/puter-js-tests
Puter JS Web Component update
2026-05-12 09:56:58 +03:00
Daniel Salazar 90d01b87ea fix: potential fix for some linux distros (#3085) 2026-05-11 23:28:59 -07:00
Daniel Salazar b0823449bc fix: don't allow dav access control credentials (#3084) 2026-05-11 19:11:02 -07:00
ProgrammerIn-wonderland 760d5d346d add missing GPT 5.4 models (#3083) 2026-05-11 21:34:23 -04:00
Daniel Salazar 4e11d1dd0f fix: updaet deepseek models (#3082) 2026-05-11 18:01:35 -07:00
ProgrammerIn-wonderland 5e8c66142d ai image video fs output (#3079)
* Add puter_output_path

* Fix error reporting in puterjs. Add precheck for puterOutputPath

* add tests

* add tests

* frontend puterjs tests
2026-05-11 20:55:45 -04:00
Miika Kuisma 189dae25ef remove hover effect from menubar shortcut labels 2026-05-11 17:58:23 +03:00
Miika Kuisma b2f4a86224 Hide keyboard shortcuts from mobile phone menubar 2026-05-11 11:47:10 +03:00
Miika Kuisma 122811eb7a Puter JS menubar web component now accepts shortcut attribute, improven submenu ux 2026-05-11 11:41:30 +03:00
Miika Kuisma bc98dacf65 Merge branch 'main' into puter-js-tests 2026-05-11 09:55:35 +03:00
Miika Kuisma b593eb5630 Tests update 2026-04-29 16:45:09 +03:00
175 changed files with 12182 additions and 7463 deletions

No files matched your search

-95
View File
@@ -1,95 +0,0 @@
# AGENTS.md
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
## Documentation Index
Use these as the source of truth before exploring further:
- [README.md](README.md) — project overview and quickstart.
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
---
## Backend
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
### Layers (top → bottom)
| Layer | Lives in | Responsibility |
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
### Cross-layer rules
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
### Extensions
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
### Language & file conventions
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
### Comments
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
- A non-trivial usage detail would otherwise trip the next reader.
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
### Tests
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
### Security & privacy
Before opening a PR, scan the diff for:
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
- Debug routes, test credentials, commented-out auth checks.
- Endpoints returning more than the caller actually needs.
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
+1 -1
View File
@@ -6,7 +6,7 @@ We at **Puter** are committed to maintaining a secure experience for our users a
The following are in scope for this program:
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
* **`puter.com`**
* **`api.puter.com`**
+1 -1
View File
@@ -86,7 +86,7 @@ Connect with the maintainers and community through these channels:
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
- Security issues? [[email protected]](mailto:[email protected])
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
- Email maintainers at [[email protected]](mailto:[email protected])
We are always happy to help you with any questions you may have. Don't hesitate to ask!
+2
View File
@@ -6,6 +6,8 @@
"domain": "puter.localhost",
"cookie_name": "puter_auth_token",
"jwt_secret": "dev-jwt-secret-change-me",
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
"allow_v1_tokens": true,
"url_signature_secret": "dev-url-signature-secret-change-me",
"allow_all_host_values": true,
"allow_no_host_header": true,
+4
View File
@@ -67,7 +67,11 @@
// ── Auth / session ──────────────────────────────────────────────────
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
"jwt_secret": "change-me",
"jwt_secret_v2": "change-me",
// Set false to retire v1 tokens entirely (ROLLOUT-1).
"allow_v1_tokens": true,
"url_signature_secret": "change-me",
"cookie_name": "puter_auth_token",
"min_pass_length": 6,
+1 -1
View File
@@ -113,7 +113,7 @@ Terhubung dengan maintainer dan komunitas melalui saluran-saluran berikut:
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
- Isu keamanan? [[email protected]](mailto:[email protected])
- Email maintainers di [[email protected]](mailto:[email protected])
- Email pengelola di [[email protected]](mailto:[email protected])
Kami selalu senang membantu Anda dengan pertanyaan apa pun yang Anda miliki. Jangan ragu untuk bertanya!
+4
View File
@@ -48,6 +48,7 @@ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MARIADB_PASSWORD=$(openssl rand -hex 32)
S3_SECRET_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
JWT_SECRET_V2=$(openssl rand -hex 64)
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
cat > .env <<EOF
@@ -78,6 +79,8 @@ cat > puter/config/config.json <<EOF
"private_app_hosting_domain_alt": "dev.puter.localhost",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
@@ -131,6 +134,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
Why these knobs:
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
+12 -9
View File
@@ -58,7 +58,10 @@ services:
fi
wait $$SERVER_PID
volumes:
- ./puter/data/valkey:/data
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
# everywhere else) — without it those distros deny container
# access to the bind mount and the service loops on EACCES.
- ./puter/data/valkey:/data:z
healthcheck:
test:
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
@@ -77,7 +80,7 @@ services:
MARIADB_USER: ${MARIADB_USER:-puter}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
volumes:
- ./puter/data/mariadb:/var/lib/mysql
- ./puter/data/mariadb:/var/lib/mysql:z
healthcheck:
# `healthcheck.sh` ships with the mariadb image; --connect verifies
# the server is accepting auth, not just listening on the socket.
@@ -102,7 +105,7 @@ services:
- "-dbPath"
- "/home/dynamodblocal/data"
volumes:
- ./puter/data/dynamo:/home/dynamodblocal/data
- ./puter/data/dynamo:/home/dynamodblocal/data:z
s3:
# RustFS — S3-compatible object storage. Drop-in alternative:
@@ -114,7 +117,7 @@ services:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
volumes:
- ./puter/data/s3:/data
- ./puter/data/s3:/data:z
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
# which preserves the Host header for S3 signature validation and
# rides the same TLS termination as Puter. Uncomment to also expose
@@ -180,7 +183,7 @@ services:
container_name: puter-ollama
restart: unless-stopped
volumes:
- ./puter/data/ollama:/root/.ollama
- ./puter/data/ollama:/root/.ollama:z
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
# ports:
@@ -257,9 +260,9 @@ services:
PGID: 1000
volumes:
# Drop your config.json here — see selfhosted/full-stack.md.
- ./puter/config:/etc/puter
- ./puter/config:/etc/puter:z
# Persistent runtime data (anything your config points at /var/puter).
- ./puter/data/puter:/var/puter
- ./puter/data/puter:/var/puter:z
healthcheck:
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
interval: 30s
@@ -279,9 +282,9 @@ services:
# Uncomment when you enable TLS in nginx/nginx.conf:
# - "${HTTPS_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
- ./puter/tls:/etc/nginx/tls:ro
- ./puter/tls:/etc/nginx/tls:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
interval: 10s
+12 -1
View File
@@ -1,7 +1,7 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
import type { Request, Response } from 'express';
import TimeAgo from 'javascript-time-ago';
import localeEn from 'javascript-time-ago/locale/en';
@@ -94,6 +94,8 @@ export const handleWhoami = async (
human_readable_age: user.timestamp
? timeago.format(new Date(user.timestamp as string))
: null,
metadata: user.metadata,
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
// OIDC revalidate URL for password-less accounts
@@ -171,6 +173,15 @@ export const handleWhoami = async (
/* best-effort */
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
delete subscription.offering.price_id;
}
res.json(details);
};
+5
View File
@@ -113,7 +113,10 @@ if ($writeConfig) {
$mariadbRootPw = New-HexSecret 32
$mariadbPw = New-HexSecret 32
$s3SecretKey = New-HexSecret 32
# Two JWT secrets: $jwtSecret is verify-only for legacy v1 tokens
# already in circulation; $jwtSecretV2 signs every new token.
$jwtSecret = New-HexSecret 64
$jwtSecretV2 = New-HexSecret 64
$urlSigSecret = New-HexSecret 64
$envContent = @"
@@ -142,6 +145,8 @@ S3_BUCKET=puter-local
private_app_hosting_domain = "app.$PuterDomain"
private_app_hosting_domain_alt = "dev.$PuterDomain"
jwt_secret = $jwtSecret
jwt_secret_v2 = $jwtSecretV2
allow_v1_tokens = $true
url_signature_secret = $urlSigSecret
database = [ordered]@{
engine = 'mysql'
+18
View File
@@ -50,6 +50,18 @@ docker compose version >/dev/null 2>&1 \
mkdir -p "$PUTER_DIR"
cd "$PUTER_DIR"
mkdir -p puter/config puter/data puter/tls
# Pre-create per-service data dirs and make them writable by any UID.
# Several upstream images run as non-root inside the container (rustfs
# uses UID 10001; dynamo is pinned to 1000 in compose), and rustfs's
# entrypoint runs as that same non-root user so it can't chown an
# already-existing bind-mounted dir. On hosts where the user that ran
# this script has a UID that doesn't match — or where docker is running
# rootless — those containers loop on EACCES at startup. 0777 on the
# bind-mount roots sidesteps the mismatch without guessing each image's
# internal UID. (Docker Desktop on macOS/Windows papers over this with
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
log "install dir: $(pwd)"
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
@@ -80,7 +92,11 @@ if [ "$write_config" = "1" ]; then
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MARIADB_PASSWORD=$(openssl rand -hex 32)
S3_SECRET_KEY=$(openssl rand -hex 32)
# Two JWT secrets: `jwt_secret` is verify-only for legacy v1 tokens
# already in circulation; `jwt_secret_v2` signs every new token.
# Both are required at boot (`jwt_secret` only when verifying v1).
JWT_SECRET=$(openssl rand -hex 64)
JWT_SECRET_V2=$(openssl rand -hex 64)
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
cat > .env <<EOF
@@ -111,6 +127,8 @@ EOF
"private_app_hosting_domain_alt": "dev.$PUTER_DOMAIN",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
+153 -1
View File
@@ -15941,6 +15941,12 @@
}
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"license": "MIT"
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
@@ -18475,6 +18481,7 @@
"license": "ISC",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"cssstyle": "^4.6.0",
"esbuild": "0.25.11",
"fs-extra": "^11.2.0",
"highlight.js": "^11.11.1",
@@ -18496,6 +18503,129 @@
"@esbuild/linux-x64": "0.25.11"
}
},
"src/docs/node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"src/docs/node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"src/docs/node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"src/docs/node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"src/docs/node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"src/docs/node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"src/docs/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
@@ -18917,6 +19047,19 @@
"node": ">= 14"
}
},
"src/docs/node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"src/docs/node_modules/data-urls": {
"version": "5.0.0",
"license": "MIT",
@@ -19033,6 +19176,8 @@
},
"src/docs/node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
@@ -19078,6 +19223,12 @@
"graceful-fs": "^4.1.6"
}
},
"src/docs/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"src/docs/node_modules/parse5": {
"version": "7.3.0",
"license": "MIT",
@@ -19199,13 +19350,14 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.3.2",
"version": "2.4.2",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"webpack-cli": "^5.1.4"
+13 -2
View File
@@ -49,7 +49,7 @@ interface Alarm {
noAlert?: boolean;
}
type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
export interface AlertPayload {
id: string;
@@ -233,8 +233,18 @@ export class AlarmClient extends PuterClient {
/**
* Create or update an alarm. If the alarm ID already exists, the
* occurrence count is incremented and a repeat alert is dispatched.
*
* `severity` is the PagerDuty severity for this alarm; omit for the
* default 'critical'. Use 'info' / 'warning' for expected-but-worth-
* tracking signals (e.g. a user hitting a rate limit) so they record
* and de-dupe like any other alarm but don't page on-call.
*/
create(id: string, message: string, fields: AlarmFields = {}): void {
create(
id: string,
message: string,
fields: AlarmFields = {},
severity?: PagerSeverity,
): void {
if (this.draining) {
if (!this.drainLogged) {
this.drainLogged = true;
@@ -256,6 +266,7 @@ export class AlarmClient extends PuterClient {
shortId: shortId(id),
message,
fields,
severity,
started: Date.now(),
timestamps: [Date.now()],
occurrences: [],
@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { existsSync, readFileSync } from 'fs';
import { basename, extname, join, resolve } from 'path';
import { existsSync, mkdirSync, readFileSync } from 'fs';
import { basename, dirname, extname, join, resolve } from 'path';
import { createContext, runInContext } from 'vm';
import type { IConfig } from '../../types';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
@@ -78,6 +78,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[43, ['0047_app-url-updates.sql']],
[44, ['0048_old-app-names-unique-tuple.sql']],
[45, ['0049_music-player-pdf-player-updates.sql']],
[46, ['0050_add_preamble_version.sql']],
[47, ['0051_sessions_v2.sql']],
[48, ['0052_sessions_v2_lookups.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -102,6 +105,10 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
: (this.config.database?.path ?? ':memory:');
const isNew = dbPath === ':memory:' || !existsSync(dbPath);
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true });
}
this.db = new Database(dbPath);
await this.runMigrations(isNew);
@@ -0,0 +1,39 @@
-- 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/>.
--
-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
-- so re-running the migration directory is safe (required — the runner
-- has no per-file tracking).
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
DELIMITER //
CREATE PROCEDURE _puter_add_subdomains_preamble_version()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
) THEN
ALTER TABLE `subdomains`
ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
END IF;
END//
DELIMITER ;
CALL _puter_add_subdomains_preamble_version();
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
@@ -0,0 +1,98 @@
-- Extend `sessions` so a single row can represent any token kind
-- (web/app/access_token/asset), carry display metadata for the
-- manage-sessions UI, and be soft-revoked with row-level expiry.
-- Mirrors SQLite migration 0050.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
-- INFORMATION_SCHEMA check, so re-running the migration directory
-- is safe (required — the runner has no per-file tracking).
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
DELIMITER //
CREATE PROCEDURE _puter_extend_sessions_v2()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
NOT NULL DEFAULT 'web';
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
) THEN
ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
) THEN
ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
) THEN
ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
) THEN
ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_user_revoked'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_parent'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_parent` (`parent_session_id`);
END IF;
END//
DELIMITER ;
CALL _puter_extend_sessions_v2();
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
@@ -0,0 +1,130 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns. Mirrors
-- SQLite migration 0052. MySQL has no partial unique indexes, so the
-- "at most one active row per (user_id, app_uid)" / "one active row
-- per legacy_token_uid" semantics are encoded via VIRTUAL generated
-- columns that are NULL when the row isn't subject to the rule —
-- MySQL allows multiple NULLs in a UNIQUE index, so non-applicable
-- rows don't conflict.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
-- directory can be replayed safely.
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
DELIMITER //
CREATE PROCEDURE _puter_sessions_v2_lookups()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_uid'
) THEN
ALTER TABLE `sessions` ADD COLUMN `app_uid` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_uid'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `legacy_token_uid` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'created_via'
) THEN
ALTER TABLE `sessions` ADD COLUMN `created_via` VARCHAR(32) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'auth_id'
) THEN
ALTER TABLE `sessions` ADD COLUMN `auth_id` VARCHAR(64) DEFAULT NULL;
END IF;
-- Generated discriminant: non-NULL only for active app-authorization rows,
-- so UNIQUE(app_unique_key) enforces "one active app session per
-- (user_id, app_uid)" while permitting any number of revoked rows.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_unique_key'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `app_unique_key` VARCHAR(150)
GENERATED ALWAYS AS (
IF(`kind` = 'app' AND `revoked_at` IS NULL,
CONCAT(`user_id`, '|', `app_uid`),
NULL)
) VIRTUAL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_unique_key'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `legacy_token_unique_key` VARCHAR(64)
GENERATED ALWAYS AS (
IF(`revoked_at` IS NULL, `legacy_token_uid`, NULL)
) VIRTUAL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_user_app_active'
) THEN
ALTER TABLE `sessions`
ADD UNIQUE INDEX `idx_sessions_user_app_active` (`app_unique_key`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_legacy_token_active'
) THEN
ALTER TABLE `sessions`
ADD UNIQUE INDEX `idx_sessions_legacy_token_active`
(`legacy_token_unique_key`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_kind_user'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_kind_user` (`kind`, `user_id`);
END IF;
END//
DELIMITER ;
CALL _puter_sessions_v2_lookups();
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
@@ -0,0 +1,18 @@
-- 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/>.
ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
@@ -0,0 +1,34 @@
-- 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/>.
-- Extend `sessions` so a single row can represent any token kind
-- (web/app/access_token/asset), carry display metadata for the
-- manage-sessions UI, and be soft-revoked.
ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
@@ -0,0 +1,45 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns.
-- - `app_uid` : binds `kind='app'` rows to their app authorization
-- target. (user_id, app_uid) is the idempotency key.
-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that
-- originally minted them.
-- - `created_via` : audit sentinel (e.g. 'legacy_backfill').
-- - `auth_id` : stable per-user identity that survives re-login
-- (PUT-1010); lets manage-sessions group by identity.
ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT;
ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT;
ALTER TABLE `sessions` ADD COLUMN `created_via` TEXT;
ALTER TABLE `sessions` ADD COLUMN `auth_id` TEXT;
-- Partial unique indexes keep "at most one active row per key" without
-- breaking the soft-revoke pattern (revoked rows stay for audit).
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
ON `sessions` (`user_id`, `app_uid`)
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
ON `sessions` (`legacy_token_uid`)
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
-- Supports manage-sessions list queries grouped by kind.
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
ON `sessions` (`kind`, `user_id`);
+15 -5
View File
@@ -19,7 +19,13 @@
import { extensionStore } from '../../extensions';
import { PuterClient } from '../types';
import { EventListener, EventMap, ListenKey, MatchingEvents } from './types';
import {
EventListener,
EventMap,
EventMetadata,
ListenKey,
MatchingEvents,
} from './types';
export class EventClient extends PuterClient {
#eventListeners: Partial<Record<ListenKey, EventListener[]>> = {};
@@ -52,7 +58,11 @@ export class EventClient extends PuterClient {
* scan of every listener.
*
*/
emit<T extends keyof EventMap>(key: T, data: EventMap[T], meta: unknown) {
emit<T extends keyof EventMap>(
key: T,
data: EventMap[T],
meta: EventMetadata,
) {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
const matchKey = (
@@ -87,7 +97,7 @@ export class EventClient extends PuterClient {
async emitAndWait<T extends keyof EventMap>(
key: T,
data: EventMap[T],
meta: unknown,
meta: EventMetadata,
) {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
@@ -129,7 +139,7 @@ export class EventClient extends PuterClient {
callback: (
key: MatchingEvents<P>,
data: EventMap[MatchingEvents<P>],
meta: unknown,
meta: EventMetadata,
) => Promise<void> | void,
) {
const listeners: EventListener[] =
@@ -140,7 +150,7 @@ export class EventClient extends PuterClient {
listener: EventListener,
key: T,
data: EventMap[T],
meta: unknown,
meta: EventMetadata,
) {
try {
await listener(key, data, meta);
+20 -5
View File
@@ -14,6 +14,9 @@
* the SocketService fans out to user-scoped channels.
*/
import { Actor } from '../../core';
import { FSEntry } from '../../stores/fs/FSEntry';
// GUI write events spread an entry plus per-event metadata into `response`.
// The exact field set varies by emit site (FSController / LegacyFSController /
// WebDAVController each project a slightly different shape) so the envelope
@@ -96,8 +99,11 @@ export type EventMap = {
};
'app.from-origin': { origin: string };
'app.privateAccess.check': {
app: unknown;
actor: unknown;
appUid: string;
userUid: string;
requestHost: string;
requestPath: string;
actor?: Actor;
result: {
allowed: boolean;
reason?: string;
@@ -162,8 +168,9 @@ export type EventMap = {
sourceObjectKey: string;
copyObjectKey: string;
};
'fs.move.node': { node: unknown; fromPath: string; toPath: string };
'fs.remove.node': { node: unknown; entry: unknown; target: unknown };
'fs.move.node': { node: FSEntry; fromPath: string; toPath: string };
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
'fs.storage.upload-progress': {
upload_tracker: unknown;
context: unknown;
@@ -199,6 +206,13 @@ export type EventMap = {
// ---- Subdomains ----
'subdomain.delete': { subdomain: string };
'subdomain.update': { subdomain: string };
'site.htmlServed': {
subdomain: string;
entry: unknown;
host: string;
requestPath: string;
mime: string;
};
// ---- Thumbnails ----
'thumbnail.read': {
@@ -264,8 +278,9 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
? Extract<EventKey, `${Prefix}.${string}`>
: P & EventKey;
export type EventMetadata = { from_outside?: boolean };
export type EventListener<K extends EventKey = EventKey> = (
key: K,
data: EventMap[K],
meta: unknown,
meta: EventMetadata,
) => Promise<void> | void;
+21 -30
View File
@@ -18,8 +18,8 @@
*/
import bcrypt from 'bcrypt';
import crypto from 'node:crypto';
import type { Request, RequestHandler, Response } from 'express';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { Controller, Get, Post } from '../../core/http/decorators.js';
@@ -41,8 +41,8 @@ import {
createSecret as otpCreateSecret,
verify as verifyOtp,
} from '../../services/auth/OTPUtil.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
@@ -94,7 +94,6 @@ export class AuthController extends PuterController {
// ── Login ───────────────────────────────────────────────────────
@Post('/login', {
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
})
@@ -191,7 +190,6 @@ export class AuthController extends PuterController {
// ── Login: OTP verification ─────────────────────────────────────
@Post('/login/otp', {
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-otp',
@@ -246,7 +244,6 @@ export class AuthController extends PuterController {
// ── Login: recovery code ────────────────────────────────────────
@Post('/login/recovery-code', {
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-recovery',
@@ -314,7 +311,6 @@ export class AuthController extends PuterController {
// ── Signup ──────────────────────────────────────────────────────
@Post('/signup', {
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
})
@@ -455,17 +451,7 @@ export class AuthController extends PuterController {
// - skip temp-user creation via `event.no_temp_user = true`
// Listeners run sequentially so multi-signal checks (rate limit +
// IP reputation + domain reputation) can short-circuit cleanly.
const validateEvent: {
req: Request;
data: Record<string, unknown>;
ip: string | null;
email: string | undefined;
allow: boolean;
no_temp_user: boolean;
requires_email_confirmation: boolean;
message: string | null;
code: string | null;
} = {
const validateEvent = {
req,
data: body,
ip: ((req.headers?.['x-forwarded-for'] as string | undefined) ||
@@ -480,6 +466,7 @@ export class AuthController extends PuterController {
requires_email_confirmation: false,
message: null,
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
};
try {
await this.clients.event?.emitAndWait(
@@ -707,14 +694,13 @@ export class AuthController extends PuterController {
// ── Logout ──────────────────────────────────────────────────────
@Post('/logout', {
subdomain: ['api', ''],
requireAuth: true,
allowUnconfirmed: true,
antiCsrf: true,
})
async handleLogout(req: Request, res: Response): Promise<void> {
// Clear the session cookie
res.clearCookie(this.config.cookie_name);
res.clearCookie(this.config.cookie_name!);
// Remove the session (fire-and-forget)
if (req.token) {
@@ -837,6 +823,16 @@ export class AuthController extends PuterController {
email_confirm_token: null,
});
// Revoke confirmation from any other accounts sharing this
// email so only the account whose owner just proved inbox
// access retains verified status.
const canonical = cleanEmail(user.email!);
await this.stores.user.unconfirmOthersByEmail(
user.id,
user.email!,
canonical,
);
await promoteToVerifiedGroup(this.stores.group, this.config, user);
try {
@@ -1552,7 +1548,6 @@ export class AuthController extends PuterController {
// ── Anti-CSRF token generation ──────────────────────────────────
@Get('/get-anticsrf-token', {
subdomain: '',
requireAuth: true,
allowUnconfirmed: true,
})
@@ -1938,7 +1933,10 @@ export class AuthController extends PuterController {
{},
);
const token = this.services.auth.getUserAppToken(req.actor!, app_uid);
const token = await this.services.auth.getUserAppToken(
req.actor!,
app_uid,
);
const missingFSPathPromise = (async () => {
// Ensure the app's per-user AppData directory exists.
@@ -1993,7 +1991,7 @@ export class AuthController extends PuterController {
token?: string;
} = { app_uid, authenticated };
if (authenticated) {
result.token = this.services.auth.getUserAppToken(
result.token = await this.services.auth.getUserAppToken(
req.actor!,
app_uid,
);
@@ -2326,7 +2324,6 @@ export class AuthController extends PuterController {
// ── Session helpers ─────────────────────────────────────────────
@Get('/get-gui-token', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
})
@@ -2348,7 +2345,6 @@ export class AuthController extends PuterController {
}
@Get('/session/sync-cookie', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
})
@@ -2382,7 +2378,7 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
res.clearCookie(this.config.cookie_name);
res.clearCookie(this.config.cookie_name!);
res.clearCookie('puter_revalidation');
await this.#cascadeDeleteUser(userId);
res.json({ success: true });
@@ -2442,7 +2438,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-password',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'passwd',
@@ -2462,7 +2457,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-username',
{
subdomain: ['api', ''],
requireUserActor: true,
requireVerified: true,
rateLimit: {
@@ -2483,7 +2477,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-email',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'change-email-start',
@@ -2503,7 +2496,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/disable-2fa',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'disable-2fa',
@@ -2523,7 +2515,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/delete-own-user',
{
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
middleware: [
@@ -124,11 +124,13 @@ const makeSyntheticDriver = () => ({
const buildController = (driver: ReturnType<typeof makeSyntheticDriver>) => {
// The controller reads `this.services?.permission` only when an actor
// is on the request; otherwise the services bag is unused. Empty
// objects are sufficient for this path.
// is on the request; otherwise the services bag is unused. The
// alarm client is invoked on the rate-limit / concurrency rejection
// paths, so stub a no-op `create`.
const clients = { alarm: { create: () => {} } };
return new DriverController(
{} as any,
{} as any,
clients as any,
{} as any,
{} as any,
{ syntheticDriver: driver } as any,
@@ -20,7 +20,7 @@
import type { Request, Response } from 'express';
import { Context } from '../../core/context.js';
import { Controller } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import {
acquireDriverConcurrent,
checkDriverRateLimit,
@@ -129,6 +129,96 @@ const XD_HTML = `<!DOCTYPE html>
* additional drivers end up in that bag before this controller is
* instantiated, so they show up here automatically.
*/
/**
* Catch-all upstream-error translator for the driver boundary.
*
* Drivers that wrap a third-party SDK (OpenAI, Anthropic, etc.) often
* let the SDK's own error class bubble — those carry an HTTP `.status`
* but are plain `Error` subclasses, not `HttpError`s, so they would
* otherwise hit the global error handler as unexpected 500s and page
* PagerDuty. Repackage them with `upstream_*` legacy codes so the
* alarm gate (server.ts) treats them as upstream failures and only
* pages on the two we actually care about (rate-limit / auth).
*
* Status extraction covers the shapes we've seen in the wild:
* - `.status` / `.statusCode` (OpenAI / Anthropic / Together / Google GenAI)
* - `.response.status` (Replicate's `ApiError`)
* - `.$metadata.httpStatusCode` (AWS SDK v3, e.g. Polly)
* - status sniffed from the message string (last resort, for providers
* that re-wrap their SDK error in `new Error(msg)` and lose the field)
*
* `HttpError`s thrown by drivers pass through untouched.
*/
const extractUpstreamStatus = (e: {
status?: number;
statusCode?: number;
response?: { status?: number };
$metadata?: { httpStatusCode?: number };
message?: string;
}): number | undefined => {
const direct = e.status ?? e.statusCode;
if (typeof direct === 'number') return direct;
const fromResponse = e.response?.status;
if (typeof fromResponse === 'number') return fromResponse;
const fromAws = e.$metadata?.httpStatusCode;
if (typeof fromAws === 'number') return fromAws;
// Message sniff (e.g. "... failed with status 422 ...").
// Only trust if it's adjacent to a status-indicating word to
// avoid matching random 4xx/5xx-looking numbers in payloads.
const msg = e.message;
if (typeof msg === 'string') {
const m = msg.match(/\bstatus(?:\s+code)?\s*[:=]?\s*(4\d\d|5\d\d)\b/i);
if (m) return Number(m[1]);
}
return undefined;
};
const translateProviderError = (err: unknown): unknown => {
if (isHttpError(err)) return err;
if (!err || typeof err !== 'object') return err;
const e = err as {
status?: number;
statusCode?: number;
response?: { status?: number };
$metadata?: { httpStatusCode?: number };
message?: string;
error?: { code?: string; type?: string; message?: string };
code?: string;
};
const status = extractUpstreamStatus(e);
if (typeof status !== 'number') 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',
fields,
});
}
if (status === 401 || status === 403) {
return new HttpError(500, msg, {
legacyCode: 'upstream_auth_failed',
fields,
});
}
if (status >= 500) {
return new HttpError(400, 'AI provider unavailable', {
legacyCode: 'upstream_provider_unavailable',
fields,
});
}
if (status >= 400) {
return new HttpError(400, msg, {
legacyCode: 'upstream_bad_request',
fields,
});
}
return err;
};
@Controller('/drivers')
export class DriverController extends PuterController {
/** iface → Map<driverName, driverInstance> */
@@ -278,6 +368,19 @@ export class DriverController extends PuterController {
if (
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
) {
// De-dupe on (iface, method) so a hot loop across many users
// aggregates as occurrences on a single low-severity alarm
// instead of fanning out one per user.
this.clients.alarm.create(
`driver_rate_limit_hit:${ifaceName}:${method}`,
`Driver rate limit hit on ${ifaceName}:${method}`,
{
iface: ifaceName,
method,
userUuid: req.actor?.user?.uuid,
},
'info',
);
throw new HttpError(429, 'Too many requests.', {
legacyCode: 'too_many_requests',
});
@@ -300,6 +403,16 @@ export class DriverController extends PuterController {
concurrentSpec,
);
if (!handle.ok) {
this.clients.alarm.create(
`driver_concurrent_limit_hit:${ifaceName}:${method}`,
`Driver concurrency limit hit on ${ifaceName}:${method}`,
{
iface: ifaceName,
method,
userUuid: req.actor?.user?.uuid,
},
'info',
);
throw new HttpError(429, 'Too many concurrent requests.', {
legacyCode: 'too_many_requests',
});
@@ -327,11 +440,13 @@ export class DriverController extends PuterController {
Context.set('driverName', requestedDriver);
// Drivers read actor/context via the Context API — no drilled args.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (fn as (...x: unknown[]) => any).call(
driver,
args,
);
let result;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (fn as (...x: unknown[]) => any).call(driver, args);
} catch (e) {
throw translateProviderError(e);
}
if (isDriverStreamResult(result)) {
res.setHeader('Content-Type', result.content_type);
@@ -434,6 +434,119 @@ describe('FSController.completeBatchWrites', () => {
expect(finalized?.wasOverwrite).toBe(true);
});
it('emits updated events with GUI metadata when overwriting via batch completion', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const target = `/${username}/Documents/overwrite-event.js`;
const firstStart = makeRes();
await withActor(actor, () =>
controller.startBatchWrites(
makeReq<SignedWriteRequest[]>({
body: [{ fileMetadata: { path: target, size: 1 } }],
actor,
}),
firstStart.res,
),
);
const [firstResponse] = firstStart.captured
.body as SignedWriteResponse[];
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: [{ uploadId: firstResponse!.sessionId }],
actor,
}),
makeRes().res,
),
);
const targetEntry = await server.stores.fsEntry.getEntryByPath(target);
expect(targetEntry).not.toBeNull();
await server.stores.subdomain.create({
userId,
subdomain: `workers.puter.${username}-worker`,
rootDirId: targetEntry!.id,
});
const secondStart = makeRes();
await withActor(actor, () =>
controller.startBatchWrites(
makeReq<SignedWriteRequest[]>({
body: [
{
fileMetadata: {
path: target,
size: 2,
overwrite: true,
},
},
],
actor,
}),
secondStart.res,
),
);
const [secondResponse] = secondStart.captured
.body as SignedWriteResponse[];
const emitSpy = vi.spyOn(server.clients.event, 'emit');
let updatedCall:
| (typeof emitSpy.mock.calls)[number]
| undefined;
try {
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: [
{
uploadId: secondResponse!.sessionId,
guiMetadata: {
operationId: 'op-123',
itemUploadId: 'item-456',
socketId: 'socket-789',
originalClientSocketId: 'socket-789',
},
},
],
actor,
}),
makeRes().res,
),
);
updatedCall = emitSpy.mock.calls.find(
([eventName]) => eventName === 'outer.gui.item.updated',
);
} finally {
emitSpy.mockRestore();
}
expect(updatedCall).toBeTruthy();
const payload = updatedCall?.[1] as {
user_id_list?: number[];
response?: Record<string, unknown>;
};
expect(payload.user_id_list).toEqual([userId]);
expect(payload.response).toMatchObject({
uid: expect.any(String),
uuid: expect.any(String),
id: expect.any(String),
path: target,
name: 'overwrite-event.js',
is_dir: false,
type: expect.stringMatching(/^application\/javascript/),
workers: [
expect.objectContaining({
subdomain: `workers.puter.${username}-worker`,
address: expect.stringContaining(`${username}-worker`),
}),
],
from_new_service: true,
operation_id: 'op-123',
item_upload_id: 'item-456',
socket_id: 'socket-789',
original_client_socket_id: 'socket-789',
});
});
it("rejects another user's session ids with a 4xx", async () => {
const a = await makeUser();
const b = await makeUser();
@@ -678,6 +791,87 @@ describe('FSController.searchEntries', () => {
expect(Array.isArray(results)).toBe(true);
expect(results.some((r) => r.name === needle)).toBe(true);
});
it('scopes app-under-user actors to their AppData root', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appUid = `app-search-${uuidv4()}`;
const appActor: Actor = { ...userActor, app: { uid: appUid } };
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
// User-owned entry outside AppData — must NOT appear for the app.
await withActor(userActor, () =>
controller.mkdirEntry(
makeReq({
body: { path: `/${username}/Documents/${needle}` },
actor: userActor,
}),
makeRes().res,
),
);
// Entry under the app's own AppData — must appear.
await withActor(userActor, () =>
controller.mkdirEntry(
makeReq({
body: {
path: `/${username}/AppData/${appUid}/${needle}`,
create_missing_parents: true,
},
actor: userActor,
}),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.searchEntries(
makeReq({ body: { query: needle }, actor: appActor }),
res,
),
);
const results = captured.body as Array<{ name: string; path: string }>;
expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(
r.path === `/${username}/AppData/${appUid}` ||
r.path.startsWith(`/${username}/AppData/${appUid}/`),
).toBe(true);
}
expect(
results.some(
(r) => r.path === `/${username}/Documents/${needle}`,
),
).toBe(false);
});
it('returns nothing for an app actor when no AppData entries match', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appUid = `app-search-${uuidv4()}`;
const appActor: Actor = { ...userActor, app: { uid: appUid } };
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
// Only seed outside AppData — the app must not be able to find it.
await withActor(userActor, () =>
controller.mkdirEntry(
makeReq({
body: { path: `/${username}/Documents/${needle}` },
actor: userActor,
}),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.searchEntries(
makeReq({ body: { query: needle }, actor: appActor }),
res,
),
);
expect(captured.body).toEqual([]);
});
});
// ── /read (readEntry, validation paths) ─────────────────────────────
+37 -79
View File
@@ -22,9 +22,11 @@ import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { effectiveActorApp } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import type {
PreparedBatchWrite,
UploadedBatchWriteItem,
@@ -57,6 +59,7 @@ import type {
ThumbnailUploadPrepareItem,
ThumbnailUploadPreparePayload,
} from './types.js';
import { toLegacyEntry } from './legacyFsHelpers.js';
class UploadProgressTracker implements UploadProgressTrackerLike {
total = 0;
progress = 0;
@@ -90,7 +93,7 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
@Controller('/fs')
export class FSController extends PuterController {
override getReportedCosts(): Record<string, unknown>[] {
override getReportedCosts() {
return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({
usageType,
ucentsPerUnit,
@@ -420,7 +423,12 @@ export class FSController extends PuterController {
const busboy = Busboy({ headers: req.headers });
busboy.on('field', (fieldName, value, info) => {
if (info.fieldnameTruncated || info.valueTruncated) {
if (
(info as unknown as { filenameTruncated: string })
.filenameTruncated ||
info.nameTruncated ||
info.valueTruncated
) {
failParse(
new HttpError(
400,
@@ -909,7 +917,7 @@ export class FSController extends PuterController {
@Post('/search', { subdomain: 'api', requireVerified: true })
async searchEntries(req: Request, res: Response) {
this.#requireActor(req);
const actor = this.#requireActor(req);
const userId = this.#getActorUserId(req);
const body = this.#toObjectRecord(req.body);
const query =
@@ -928,6 +936,7 @@ export class FSController extends PuterController {
userId,
query,
limit ?? 200,
this.#appDataScopeForActor(actor),
);
res.json(results);
}
@@ -1648,24 +1657,6 @@ export class FSController extends PuterController {
normalizedFileMetadata.multipartPartSize = multipartPartSize;
}
const bucket = this.#firstDefined(
metadataRecord.bucket,
fallbackRecord.bucket,
);
if (typeof bucket === 'string' && bucket.length > 0) {
normalizedFileMetadata.bucket = bucket;
}
const bucketRegion = this.#firstDefined(
metadataRecord.bucketRegion,
metadataRecord.bucket_region,
fallbackRecord.bucketRegion,
fallbackRecord.bucket_region,
);
if (typeof bucketRegion === 'string' && bucketRegion.length > 0) {
normalizedFileMetadata.bucketRegion = bucketRegion;
}
const associatedAppId = this.#toNumber(
this.#firstDefined(
metadataRecord.associatedAppId,
@@ -1787,7 +1778,8 @@ export class FSController extends PuterController {
pathToNormalize = `/${username}${pathToNormalize.slice(1)}`;
}
let normalizedPath = pathPosix.normalize(pathToNormalize);
assertNormalized(pathToNormalize);
let normalizedPath = pathToNormalize;
if (!normalizedPath.startsWith('/')) {
normalizedPath = `/${normalizedPath}`;
}
@@ -2032,56 +2024,7 @@ export class FSController extends PuterController {
}
async #toGuiFsEntry(entry: FSEntry): Promise<Record<string, unknown>> {
const dirpath = pathPosix.dirname(entry.path);
const extension = pathPosix.extname(entry.name).slice(1).toLowerCase();
const response = {
id: entry.uuid,
uid: entry.uuid,
uuid: entry.uuid,
parent_id: entry.parentUid,
parent_uid: entry.parentUid,
path: entry.path,
dirname: dirpath,
dirpath,
name: entry.name,
is_dir: entry.isDir,
is_shortcut: entry.isShortcut ? 1 : 0,
shortcut_to: entry.shortcutTo,
type: entry.isDir ? 'folder' : extension,
writable: true,
is_public: entry.isPublic,
thumbnail: entry.thumbnail,
immutable: entry.immutable,
metadata: entry.metadata,
modified: entry.modified,
created: entry.created,
accessed: entry.accessed,
size: entry.size,
};
if (
typeof response.thumbnail === 'string' &&
response.thumbnail.length > 0
) {
const thumbnailEntry = {
uuid: entry.uuid,
thumbnail: response.thumbnail,
};
// emitAndWait — listener rewrites s3:// / legacy URLs into
// time-limited signed URLs on the payload object.
await this.clients.event.emitAndWait(
'thumbnail.read',
thumbnailEntry,
{},
);
response.thumbnail =
typeof thumbnailEntry.thumbnail === 'string' &&
thumbnailEntry.thumbnail.length > 0
? thumbnailEntry.thumbnail
: null;
}
return response;
return toLegacyEntry(this.clients.event, entry);
}
async #emitGuiWriteEvent(
@@ -2091,7 +2034,7 @@ export class FSController extends PuterController {
): Promise<void> {
const response = {
...(await this.#toGuiFsEntry(fsEntry)),
...this.#toEventGuiMetadata(guiMetadata, false),
...this.#toEventGuiMetadata(guiMetadata),
from_new_service: true,
};
await this.clients.event.emit(
@@ -2142,6 +2085,20 @@ export class FSController extends PuterController {
return pathParts.length >= 2 && pathParts[1] === 'AppData';
}
// If `actor` is effectively scoped to an app (directly or through an
// access-token issuer chain), return that app's AppData root under the
// actor's user. Returns undefined for pure user actors. The store anchors
// search results to this path so app actors can't see entries outside
// their AppData via `/fs/search`.
#appDataScopeForActor(actor: Actor): string | undefined {
const app = effectiveActorApp(actor);
if (!app) return undefined;
const username = actor.user?.username;
if (typeof username !== 'string' || username.length === 0)
return undefined;
return `/${username}/AppData/${app.uid}`;
}
#estimateDataUrlSize(dataUrl: string): number {
const commaIndex = dataUrl.indexOf(',');
const base64 =
@@ -2235,7 +2192,7 @@ export class FSController extends PuterController {
return null;
}
return { index, contentType, size };
return { index, contentType, size } as ThumbnailUploadPrepareItem;
}
async #attachSignedThumbnailUploadTargets(
@@ -2255,11 +2212,12 @@ export class FSController extends PuterController {
const payload: ThumbnailUploadPreparePayload = {
items: prepareItems.map(
(item): ThumbnailUploadPrepareItem => ({
index: item.index,
contentType: item.contentType,
...(item.size !== undefined ? { size: item.size } : {}),
}),
(item): ThumbnailUploadPrepareItem =>
({
index: item.index,
contentType: item.contentType,
...(item.size !== undefined ? { size: item.size } : {}),
}) as ThumbnailUploadPrepareItem,
),
};
// emitAndWait — listeners populate `uploadUrl` / `thumbnailUrl` on
@@ -813,6 +813,57 @@ describe('LegacyFSController.search', () => {
expect(Array.isArray(results)).toBe(true);
expect(results.some((r) => r.name === needle)).toBe(true);
});
it('scopes app-under-user actors to their AppData root', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appUid = `app-legacy-search-${uuidv4()}`;
const appActor: Actor = { ...userActor, app: { uid: appUid } };
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
await withActor(userActor, () =>
controller.mkdir(
makeReq({
body: { path: `/${username}/Documents/${needle}` },
actor: userActor,
}),
makeRes().res,
),
);
await withActor(userActor, () =>
controller.mkdir(
makeReq({
body: {
path: `/${username}/AppData/${appUid}/${needle}`,
create_missing_parents: true,
},
actor: userActor,
}),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.search(
makeReq({ body: { query: needle }, actor: appActor }),
res,
),
);
const results = captured.body as Array<{ path: string }>;
expect(results.length).toBeGreaterThan(0);
for (const r of results) {
expect(
r.path === `/${username}/AppData/${appUid}` ||
r.path.startsWith(`/${username}/AppData/${appUid}/`),
).toBe(true);
}
expect(
results.some(
(r) => r.path === `/${username}/Documents/${needle}`,
),
).toBe(false);
});
});
// ── read (validation paths only) ────────────────────────────────────
@@ -2032,3 +2083,197 @@ describe('LegacyFSController.readdir extras', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── suggestApps (ACL-gated) ─────────────────────────────────────────
describe('LegacyFSController.suggestApps', () => {
it('refuses to look up entries an app actor cannot see', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appActor: Actor = {
...userActor,
app: { uid: `app-suggest-${uuidv4()}` },
};
const target = `/${username}/Documents/probe.txt`;
await withActor(userActor, () =>
controller.touch(
makeReq({
body: { path: target, set_modified_to_now: true },
actor: userActor,
}),
makeRes().res,
),
);
const spy = vi.spyOn(
server.services.suggestedApps,
'getSuggestedApps',
);
try {
const { res } = makeRes();
await withActor(appActor, () =>
controller.suggestApps(
makeReq({ body: { path: target }, actor: appActor }),
res,
),
);
expect(spy).toHaveBeenLastCalledWith({
name: undefined,
path: undefined,
});
} finally {
spy.mockRestore();
}
});
it('forwards entry name/path to the suggester for the owning user', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const target = `/${username}/Documents/probe2.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({
body: { path: target, set_modified_to_now: true },
actor,
}),
makeRes().res,
),
);
const spy = vi.spyOn(
server.services.suggestedApps,
'getSuggestedApps',
);
try {
const { res } = makeRes();
await withActor(actor, () =>
controller.suggestApps(
makeReq({ body: { path: target }, actor }),
res,
),
);
expect(spy).toHaveBeenLastCalledWith({
name: 'probe2.txt',
path: target,
});
} finally {
spy.mockRestore();
}
});
});
// ── readdirSubdomains ───────────────────────────────────────────────
describe('LegacyFSController.readdirSubdomains', () => {
it('returns an empty array for app-under-user actors', async () => {
const { actor: userActor, userId } = await makeUser();
const appActor: Actor = {
...userActor,
app: { uid: `app-subd-${uuidv4()}` },
};
await server.clients.db.write(
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)',
[uuidv4(), `sd-${uuidv4().slice(0, 8)}`, userId],
);
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.readdirSubdomains(
makeReq({ body: {}, actor: appActor }),
res,
),
);
expect(captured.body).toEqual([]);
});
it("returns the user actor's own subdomain rows", async () => {
const { actor, userId } = await makeUser();
const sd = `sd-${uuidv4().slice(0, 8)}`;
await server.clients.db.write(
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)',
[uuidv4(), sd, userId],
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.readdirSubdomains(
makeReq({ body: {}, actor }),
res,
),
);
const rows = captured.body as Array<{ subdomain: string }>;
expect(rows.some((r) => r.subdomain === sd)).toBe(true);
});
});
// ── updateFsentryThumbnail ──────────────────────────────────────────
describe('LegacyFSController.updateFsentryThumbnail', () => {
it('rejects an app actor probing entries outside AppData with 404', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appActor: Actor = {
...userActor,
app: { uid: `app-thumb-${uuidv4()}` },
};
const target = `/${username}/Documents/thumbme.txt`;
await withActor(userActor, () =>
controller.touch(
makeReq({
body: { path: target, set_modified_to_now: true },
actor: userActor,
}),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(target);
const { res } = makeRes();
await expect(
withActor(appActor, () =>
controller.updateFsentryThumbnail(
makeReq({
body: {
uid: entry!.uuid,
thumbnail: 'data:image/png;base64,AA==',
},
actor: appActor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('updates the thumbnail for the owning user actor', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const target = `/${username}/Documents/thumbme2.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({
body: { path: target, set_modified_to_now: true },
actor,
}),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(target);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.updateFsentryThumbnail(
makeReq({
body: {
uid: entry!.uuid,
thumbnail: 'data:image/png;base64,AA==',
},
actor,
}),
res,
),
);
const body = captured.body as { thumbnail: string };
expect(typeof body.thumbnail).toBe('string');
});
});
+217 -104
View File
@@ -22,7 +22,7 @@ import type { Request, RequestHandler, Response } from 'express';
import { contentType as contentTypeFromMime } from 'mime-types';
import { posix as pathPosix } from 'node:path';
import type { Actor } from '../../core/actor.js';
import { isAccessTokenActor } from '../../core/actor.js';
import { effectiveActorApp, isAccessTokenActor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
@@ -43,6 +43,7 @@ import {
signingConfigFromAppConfig,
toLegacyEntry,
} from './legacyFsHelpers.js';
import { RouteOptions } from '../../core/http/index.js';
/**
* Legacy FS routes, implemented as thin shims over `FSService`.
@@ -79,11 +80,16 @@ export class LegacyFSController extends PuterController {
#additionalCache: RouterCache = new Map();
registerRoutes(router: PuterRouter): void {
const apiOptions = { subdomain: 'api', requireVerified: true } as const;
const apiOptions = {
subdomain: 'api',
requireVerified: true,
} as RouteOptions;
// Signed-URL routes: the handler validates the URL signature itself,
// so no auth gate is applied (matches v1, which mounted these routers
// with no middleware).
const signedOptions = { subdomain: 'api' } as const;
const signedOptions = {
subdomain: 'api',
} as RouteOptions;
// Core filesystem_api routes — direct handlers over the FS service.
router.post('/stat', apiOptions, this.stat);
@@ -98,7 +104,11 @@ export class LegacyFSController extends PuterController {
router.get('/read', apiOptions, this.read);
router.get(
'/token-read',
{ subdomain: 'api', requireVerified: false },
{
subdomain: 'api',
requireVerified: false,
allowAccessToken: true,
},
this.tokenRead,
);
@@ -185,35 +195,7 @@ export class LegacyFSController extends PuterController {
res.json({ recommended, recent });
});
router.post('/suggest_apps', apiOptions, async (req, res) => {
const suggestSvc = this.services.suggestedApps;
if (!suggestSvc?.getSuggestedApps) {
res.json([]);
return;
}
const body = req.body ?? {};
// Client sends { uid } or { path } identifying a file entry.
// Resolve the entry to get its name/path for extension detection.
let entryName: string | undefined;
let entryPath: string | undefined;
if (body.uid || body.path) {
try {
const entry = await resolveV1Selector(
this.stores.fsEntry,
body,
);
entryName = entry?.name;
entryPath = entry?.path;
} catch {
// If we can't resolve, fall back to empty
}
}
const suggestions = await suggestSvc.getSuggestedApps({
name: entryName,
path: entryPath,
});
res.json(suggestions);
});
router.post('/suggest_apps', apiOptions, this.suggestApps);
// puter-js polls this to decide whether to purge its in-memory FS
// cache. SocketService bumps a per-user Redis key on every
@@ -241,70 +223,11 @@ export class LegacyFSController extends PuterController {
},
);
// ── POST /readdir-subdomains ────────────────────────────────
router.post(
'/readdir-subdomains',
apiOptions,
async (req: Request, res: Response) => {
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const rows = await this.clients.db.read(
'SELECT `subdomain`, `root_dir_id`, `uuid`, `ts` FROM `subdomains` WHERE `user_id` = ?',
[userId],
);
res.json(rows);
},
);
// ── POST /update-fsentry-thumbnail ──────────────────────────
router.post('/readdir-subdomains', apiOptions, this.readdirSubdomains);
router.post(
'/update-fsentry-thumbnail',
apiOptions,
async (req: Request, res: Response) => {
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const { uid, thumbnail } = (req.body ?? {}) as {
uid?: string;
thumbnail?: string;
};
if (!uid)
throw new HttpError(400, 'Missing `uid`', {
legacyCode: 'bad_request',
});
if (!thumbnail)
throw new HttpError(400, 'Missing `thumbnail`', {
legacyCode: 'bad_request',
});
const entry = await this.stores.fsEntry.getEntryByUuid(uid);
if (!entry || entry.userId !== userId)
throw new HttpError(403, 'Access denied', {
legacyCode: 'forbidden',
});
// Emit thumbnail.created so the thumbnails extension can S3-upload.
// emitAndWait is required — the extension rewrites `event.url`
// from a data URL to an `s3://` pointer, and the DB write below
// needs to see that rewrite.
const event = { url: thumbnail };
await this.clients.event.emitAndWait(
'thumbnail.created',
event,
{},
);
await this.clients.db.write(
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
[event.url, uid],
);
res.json({ thumbnail: event.url });
},
this.updateFsentryThumbnail,
);
for (const key of Object.keys(additionalRoutePaths)) {
@@ -746,8 +669,108 @@ export class LegacyFSController extends PuterController {
res.send('');
};
suggestApps = async (req: Request, res: Response): Promise<void> => {
const suggestSvc = this.services.suggestedApps;
if (!suggestSvc?.getSuggestedApps) {
res.json([]);
return;
}
const actor = this.#requireActor(req);
const body = asRecord(req.body);
let entryName: string | undefined;
let entryPath: string | undefined;
if (body.uid || body.path) {
try {
const entry = await resolveV1Selector(
this.stores.fsEntry,
body,
);
// Suggestions leak the entry's extension and existence;
// gate on `see` so app actors can't probe outside scope.
if (entry?.path) {
await assertAccess(
this.services.acl,
this.services.fs,
actor,
entry.path,
'see',
);
}
entryName = entry?.name;
entryPath = entry?.path;
} catch {
// Unresolvable or ACL-denied → empty suggestions.
}
}
const suggestions = await suggestSvc.getSuggestedApps({
name: entryName,
path: entryPath,
});
res.json(suggestions);
};
readdirSubdomains = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
// Subdomain enumeration is a user-level concern; app actors would
// otherwise see root_dir uids pointing outside their AppData scope.
if (effectiveActorApp(actor)) {
res.json([]);
return;
}
const userId = this.#getActorUserId(req);
const rows = await this.clients.db.read(
'SELECT `subdomain`, `root_dir_id`, `uuid`, `ts` FROM `subdomains` WHERE `user_id` = ?',
[userId],
);
res.json(rows);
};
updateFsentryThumbnail = async (
req: Request,
res: Response,
): Promise<void> => {
const actor = this.#requireActor(req);
const { uid, thumbnail } = asRecord(req.body) as {
uid?: string;
thumbnail?: string;
};
if (!uid)
throw new HttpError(400, 'Missing `uid`', {
legacyCode: 'bad_request',
});
if (!thumbnail)
throw new HttpError(400, 'Missing `thumbnail`', {
legacyCode: 'bad_request',
});
const entry = await this.stores.fsEntry.getEntryByUuid(uid);
if (!entry || !entry.path)
throw new HttpError(404, `Entry not found: uid=${uid}`, {
legacyCode: 'subject_does_not_exist',
});
await assertAccess(
this.services.acl,
this.services.fs,
actor,
entry.path,
'write',
);
// emitAndWait is required: the thumbnails extension rewrites
// `event.url` from a data URL to an `s3://` pointer, and the DB
// write below needs to see that rewrite.
const event = { url: thumbnail };
await this.clients.event.emitAndWait('thumbnail.created', event, {});
await this.clients.db.write(
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
[event.url, uid],
);
res.json({ thumbnail: event.url });
};
search = async (req: Request, res: Response): Promise<void> => {
this.#requireActor(req);
const actor = this.#requireActor(req);
const userId = this.#getActorUserId(req);
const body = asRecord(req.body);
const query = getString(body, 'query', 'text') ?? '';
@@ -756,7 +779,21 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
const results = await this.services.fs.searchByName(userId, query, 200);
// App-under-user actors only see entries within their AppData root;
// user actors are unscoped. Mirrors the ACL short-circuit in
// ACLService.check.
const app = effectiveActorApp(actor);
const username = actor.user?.username;
const pathScope =
app && typeof username === 'string' && username.length > 0
? `/${username}/AppData/${app.uid}`
: undefined;
const results = await this.services.fs.searchByName(
userId,
query,
200,
pathScope,
);
const shaped = await Promise.all(
results.map((r) => toLegacyEntry(this.clients.event, r)),
);
@@ -927,7 +964,10 @@ export class LegacyFSController extends PuterController {
legacyCode: 'not_found',
});
grantApp = { uid: app.uid };
result.token = this.services.auth.getUserAppToken(actor, app.uid);
result.token = await this.services.auth.getUserAppToken(
actor,
app.uid,
);
}
for (const rawItem of items) {
@@ -991,7 +1031,12 @@ export class LegacyFSController extends PuterController {
}
const signed = signEntry(entry, signingCfg);
result.signatures.push({ ...signed, path: entry.path });
if (finalAction !== 'write') {
const { write_url: _, ...rest } = signed;
result.signatures.push({ ...rest, path: entry.path });
} else {
result.signatures.push({ ...signed, path: entry.path });
}
} catch {
// Silently skip unresolvable items.
result.signatures.push({});
@@ -1044,7 +1089,32 @@ export class LegacyFSController extends PuterController {
const operation =
typeof query.operation === 'string' ? query.operation : 'write';
// `write` — multipart upload, streamed directly to the v2 write path.
// A valid write signature authorises overwriting the file's bytes,
// not structural changes. Restrict copy/move/mkdir/rename/delete/trash
// to a caller authenticated as the owner — otherwise a recipient of a
// write-share could relocate or destroy the source via this endpoint.
if (operation !== 'write' && req.actor?.user?.id !== userId) {
throw new HttpError(
403,
`'${operation}' via signed URL requires owner authentication`,
{ legacyCode: 'forbidden' },
);
}
// ACL re-check: require an authenticated actor with write
// permission. A valid write signature alone is not sufficient —
// the caller must also pass ACL, preventing exploitation of
// leaked write URLs by read-only share recipients.
const callerActor = this.#requireActor(req);
if (operation === 'write') {
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
targetEntry.path,
'write',
);
}
if (operation === 'write') {
const body = asRecord(req.body);
const parentEntry = targetEntry.isDir
@@ -1077,8 +1147,18 @@ export class LegacyFSController extends PuterController {
}
// Non-write operations: route to existing service methods and sign the result.
// The signature alone authorises only byte writes to `targetEntry`. Structural
// ops (mkdir/rename/copy/move/delete) require a caller actor with explicit
// ACL on the affected paths — mirroring the unsigned counterparts above.
const record = asRecord(req.body);
if (operation === 'mkdir') {
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
targetEntry.path,
'write',
);
const folderName =
typeof record.name === 'string'
? record.name
@@ -1100,12 +1180,26 @@ export class LegacyFSController extends PuterController {
throw new HttpError(400, '`new_name` required', {
legacyCode: 'bad_request',
});
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
targetEntry.path,
'write',
);
const renamed = await this.services.fs.rename(targetEntry, newName);
await this.#emitGuiEvent('outer.gui.item.updated', renamed);
res.json({ ...signEntry(renamed, signingCfg), path: renamed.path });
return;
}
if (operation === 'delete' || operation === 'trash') {
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
targetEntry.path,
'write',
);
// Treat trash == delete (recursive). Most clients just call delete
// directly; if a trash folder becomes important we can revisit.
await this.services.fs.remove(userId, {
@@ -1129,6 +1223,20 @@ export class LegacyFSController extends PuterController {
this.stores.fsEntry,
destRef,
);
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
targetEntry.path,
operation === 'copy' ? 'read' : 'write',
);
await assertAccess(
this.services.acl,
this.services.fs,
callerActor,
destinationParent.path,
'write',
);
const method = operation === 'copy' ? 'copy' : 'move';
const result = await this.services.fs[method](userId, {
source: targetEntry,
@@ -1193,12 +1301,14 @@ export class LegacyFSController extends PuterController {
}
// Directory: return a signed listing of direct children.
// The caller only proved read access, so strip write_url from
// each child to prevent privilege escalation via /writeFile.
if (entry.isDir) {
const children = await this.services.fs.listDirectory(entry.uuid);
const signedChildren = children.map((child) => ({
...signEntry(child, signingCfg),
path: child.path,
}));
const signedChildren = children.map((child) => {
const { write_url: _, ...rest } = signEntry(child, signingCfg);
return { ...rest, path: child.path };
});
res.json(signedChildren);
return;
}
@@ -1290,7 +1400,10 @@ export class LegacyFSController extends PuterController {
{},
{ reason: 'open_item' },
);
token = this.services.auth.getUserAppToken(actor, defaultAppUid);
token = await this.services.auth.getUserAppToken(
actor,
defaultAppUid,
);
}
const signingCfg = signingConfigFromAppConfig(this.config);
@@ -23,7 +23,7 @@ import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { FSService } from '../../services/fs/FSService.js';
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
import type { Actor } from '../../core/actor.js';
import { isAppActor, type Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import type { EventClient } from '../../clients/event/EventClient.js';
import { HttpError } from '../../core/http/HttpError.js';
@@ -202,8 +202,8 @@ export async function assertAccess(
// App-under-user actors see denials as 404 "subject_does_not_exist"
// so existence of a sibling user's / other-app's files isn't leaked
// through the error code. User-actor denials keep the real 403.
const isAppActor = Boolean((actor as { app?: unknown })?.app);
if (isAppActor) {
if (isAppActor(actor)) {
throw new HttpError(404, `Entry not found: path=${path}`, {
legacyCode: 'subject_does_not_exist',
});
+1
View File
@@ -52,6 +52,7 @@ export interface ThumbnailUploadPrepareItem {
size?: number;
uploadUrl?: string;
thumbnailUrl?: string;
item_uid: string;
}
export interface ThumbnailUploadPreparePayload {
@@ -38,7 +38,7 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification.
* Sets `acknowledged` timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', { subdomain: 'api', requireAuth: true })
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
async markAck(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
@@ -77,7 +77,7 @@ export class NotificationController extends PuterController {
* POST /notif/mark-read — user saw a notification.
* Sets `shown` timestamp; pushes ack event to sockets.
*/
@Post('/mark-read', { subdomain: 'api', requireAuth: true })
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
async markRead(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
@@ -73,11 +73,6 @@ const makeUserActor = (): Actor => ({
user: { id: 7, uuid: 'u-7', username: 'alice' },
});
const makeAppActor = (): Actor => ({
user: { id: 7, uuid: 'u-7', username: 'alice' },
app: { id: 1, uid: 'app-uid' },
});
interface CapturedResponse {
statusCode: number;
body: unknown;
@@ -193,13 +188,23 @@ describe('PuterAIController.registerRoutes', () => {
]),
);
const chatRoute = calls.find(
(c) => c.path === '/puterai/openai/v1/chat/completions',
);
expect(chatRoute?.opts).toEqual({
subdomain: 'api',
requireAuth: true,
});
// The four upstream-proxy routes are user-only: app actors must
// call puter-chat-completion directly. The `requireUserActor`
// route option wires up the gate that enforces this, so each
// proxy route gets the assertion (not just chat/completions).
const userOnlyPaths = [
'/puterai/openai/v1/chat/completions',
'/puterai/openai/v1/completions',
'/puterai/openai/v1/responses',
'/puterai/anthropic/v1/messages',
];
for (const path of userOnlyPaths) {
const route = calls.find((c) => c.path === path);
expect(route?.opts).toEqual({
subdomain: 'api',
requireUserActor: true,
});
}
const modelsRoute = calls.find(
(c) => c.path === '/puterai/chat/models',
);
@@ -213,18 +218,10 @@ describe('PuterAIController.registerRoutes', () => {
// ── /openai/v1/chat/completions ─────────────────────────────────────
describe('PuterAIController.openaiChatCompletions', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiChatCompletions(
makeReq({
body: { messages: [], model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// Note: app-actor rejection (403) is enforced by the `requireUserActor`
// route gate and is asserted in the registerRoutes test above. The
// handler itself no longer does that check, so unit-testing it here
// would require invoking the gate stack.
it('rejects bodies missing a messages array with HttpError 400', async () => {
const { res } = makeRes();
@@ -272,13 +269,13 @@ describe('PuterAIController.openaiChatCompletions', () => {
const body = captured.body as Record<string, unknown>;
expect(body.object).toBe('chat.completion');
expect(body.model).toBe('gpt-test');
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
{
index: 0,
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
},
);
expect(
(body.choices as Array<Record<string, unknown>>)[0],
).toMatchObject({
index: 0,
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
});
// total_tokens is computed from prompt + completion.
expect(body.usage).toEqual({
prompt_tokens: 4,
@@ -392,18 +389,8 @@ describe('PuterAIController.openaiChatCompletions', () => {
// ── /openai/v1/completions ──────────────────────────────────────────
describe('PuterAIController.openaiCompletions', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiCompletions(
makeReq({
body: { prompt: 'hi', model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects a non-string prompt with HttpError 400', async () => {
const { res } = makeRes();
@@ -443,13 +430,13 @@ describe('PuterAIController.openaiCompletions', () => {
const body = captured.body as Record<string, unknown>;
expect(body.object).toBe('text_completion');
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
{
text: 'response',
index: 0,
finish_reason: 'stop',
},
);
expect(
(body.choices as Array<Record<string, unknown>>)[0],
).toMatchObject({
text: 'response',
index: 0,
finish_reason: 'stop',
});
expect((body.id as string).startsWith('cmpl-')).toBe(true);
});
});
@@ -457,18 +444,8 @@ describe('PuterAIController.openaiCompletions', () => {
// ── /openai/v1/responses ────────────────────────────────────────────
describe('PuterAIController.openaiResponses', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiResponses(
makeReq({
body: { input: 'hi', model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects providers other than openai-responses with HttpError 400', async () => {
const { res } = makeRes();
@@ -537,18 +514,8 @@ describe('PuterAIController.openaiResponses', () => {
// ── /anthropic/v1/messages ──────────────────────────────────────────
describe('PuterAIController.anthropicMessages', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.anthropicMessages(
makeReq({
body: { messages: [], model: 'claude-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects bodies missing a messages array with HttpError 400', async () => {
const { res } = makeRes();
@@ -655,9 +622,8 @@ describe('PuterAIController model listing', () => {
const captureGetHandler = (
path: string,
): ((req: Request, res: Response) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((p: string, _opts: unknown, h: never) => {
@@ -719,9 +685,8 @@ describe('PuterAIController videoProxy', () => {
req: Request,
res: Response,
) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((path: string, _opts: unknown, h: never) => {
@@ -799,8 +764,9 @@ describe('PuterAIController videoProxy', () => {
it('500s when url_signature_secret is not configured', async () => {
// Temporarily blank the secret so the controller hits the
// 500 branch instead of the constant-time-compare gate.
const cfg = (controller as unknown as { config: Record<string, unknown> })
.config;
const cfg = (
controller as unknown as { config: Record<string, unknown> }
).config;
const orig = cfg.url_signature_secret;
cfg.url_signature_secret = undefined;
try {
@@ -826,8 +792,9 @@ describe('PuterAIController videoProxy', () => {
// Hit the post-signature `provider !== 'gemini'` branch by
// computing a valid signature for a known fileId/expires combo
// and then sending a different provider in the query.
const cfg = (controller as unknown as { config: Record<string, unknown> })
.config;
const cfg = (
controller as unknown as { config: Record<string, unknown> }
).config;
const secret = cfg.url_signature_secret as string;
const fileId = 'abc-123';
const expires = String(Math.floor(Date.now() / 1000) + 60);
@@ -854,11 +821,16 @@ describe('PuterAIController videoProxy', () => {
});
it('500s when provider=gemini but no Gemini API key is configured', async () => {
const cfg = (controller as unknown as {
config: Record<string, unknown> & {
providers?: Record<string, Record<string, unknown> | undefined>;
};
}).config;
const cfg = (
controller as unknown as {
config: Record<string, unknown> & {
providers?: Record<
string,
Record<string, unknown> | undefined
>;
};
}
).config;
const secret = cfg.url_signature_secret as string;
const fileId = 'gemini-no-key';
const expires = String(Math.floor(Date.now() / 1000) + 60);
@@ -1425,7 +1397,11 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
);
const tools = completeSpy.mock.calls[0]![0].tools as Array<{
type: string;
function: { name: string; description: string; parameters: unknown };
function: {
name: string;
description: string;
parameters: unknown;
};
}>;
expect(tools[0]?.type).toBe('function');
expect(tools[0]?.function.name).toBe('lookup');
@@ -1483,9 +1459,7 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
res,
);
const body = captured.body as { content: Array<{ text: string }> };
expect(body.content).toEqual([
{ type: 'text', text: 'hello world' },
]);
expect(body.content).toEqual([{ type: 'text', text: 'hello world' }]);
});
it('reads tool_use blocks from message.content (not just message.tool_calls)', async () => {
@@ -1536,9 +1510,8 @@ describe('PuterAIController model details', () => {
const captureGetHandler = (
path: string,
): ((req: Request, res: Response) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((p: string, _opts: unknown, h: never) => {
@@ -17,19 +17,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import { isAppActor } from '../../core/actor.js';
import { RouteOptions } from '../../core/http/index.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import type {
ICompleteArguments,
IChatCompleteResult,
ICompleteArguments,
} from '../../drivers/ai-chat/types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import { PuterController } from '../types.js';
const GEMINI_DOWNLOAD_BASE =
'https://generativelanguage.googleapis.com/download/v1beta/files';
@@ -49,7 +49,10 @@ const GEMINI_DOWNLOAD_BASE =
*/
export class PuterAIController extends PuterController {
registerRoutes(router: PuterRouter): void {
const apiAuthOpts = { subdomain: 'api', requireAuth: true } as const;
const apiAuthOpts = {
subdomain: 'api',
requireUserActor: true,
} as RouteOptions;
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
// Every route below carries the `/puterai` prefix for wire
@@ -229,8 +232,6 @@ export class PuterAIController extends PuterController {
req: Request,
res: Response,
): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -396,8 +397,6 @@ export class PuterAIController extends PuterController {
// ── /openai/v1/completions ──────────────────────────────────────
openaiCompletions = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -526,8 +525,6 @@ export class PuterAIController extends PuterController {
// ── /openai/v1/responses ────────────────────────────────────────
openaiResponses = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -838,8 +835,6 @@ export class PuterAIController extends PuterController {
// ── /anthropic/v1/messages ──────────────────────────────────────
anthropicMessages = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -1052,17 +1047,6 @@ export class PuterAIController extends PuterController {
});
return driver;
}
#rejectAppActor(req: Request): void {
// Proxy routes are user-only; apps must call puter-chat-completion directly.
if (isAppActor(req.actor)) {
throw new HttpError(
403,
'App actors may not proxy to upstream AI APIs',
{ legacyCode: 'forbidden' },
);
}
}
}
// ── Shared helpers ──────────────────────────────────────────────────
+4 -1
View File
@@ -92,7 +92,10 @@ export const PuterController =
public onServerShutdown() {
return;
}
public async getReportedCosts() {
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>[] {
return [];
}
public registerRoutes(_router: PuterRouter) {}
@@ -1,10 +1,13 @@
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
import type { Request, Response } from 'express';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { WebDAVController } from './WebDAVController.js';
let server: PuterServer;
@@ -101,6 +104,35 @@ const basicAuth = (user: string, pass: string) =>
const noop = vi.fn();
const makeUser = async () => {
const username = `webdav-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
username: refreshed.username,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
},
},
};
};
describe('WebDAVController', () => {
describe('route registration', () => {
it('registers a single catch-all use() route', () => {
@@ -396,6 +428,53 @@ describe('WebDAVController', () => {
);
expect(captured.statusCode).toBe(422);
});
it('emits GUI-safe item events without leaking numeric fsentry ids', async () => {
const { actor, userId, username } = await makeUser();
const target = `/${username}/Documents/webdav-event.txt`;
const { res, captured } = makeRes();
const req = Object.assign(
Readable.from(['hello']),
makeReq({
method: 'PUT',
path: target,
headers: { 'content-length': '5' },
actor,
}),
) as Request;
const emitSpy = vi.spyOn(server.clients.event, 'emit');
let addedCall:
| (typeof emitSpy.mock.calls)[number]
| undefined;
try {
await dispatchMiddleware(req, res, noop);
await new Promise((resolve) => setTimeout(resolve, 0));
addedCall = emitSpy.mock.calls.find(
([eventName]) => eventName === 'outer.gui.item.added',
);
} finally {
emitSpy.mockRestore();
}
expect(captured.statusCode).toBe(201);
expect(addedCall).toBeTruthy();
const payload = addedCall?.[1] as {
user_id_list?: number[];
response?: Record<string, unknown>;
};
expect(payload.user_id_list).toEqual([userId]);
expect(payload.response).toMatchObject({
id: expect.any(String),
uid: expect.any(String),
uuid: expect.any(String),
path: target,
from_new_service: true,
});
expect(typeof payload.response?.id).toBe('string');
expect(payload.response?.id).toBe(payload.response?.uuid);
expect(payload.response).not.toHaveProperty('userId');
});
});
describe('DELETE', () => {
@@ -516,7 +595,7 @@ describe('WebDAVController', () => {
await dispatchMiddleware(
makeReq({
method: 'LOCK',
path: '/lockable-file.txt',
path: '/test/lockable-file.txt',
actor: {
user: {
id: 1,
@@ -539,8 +618,28 @@ describe('WebDAVController', () => {
expect(captured.headers['lock-token']).toContain('urn:uuid:');
});
it('rejects locking a path the user has no write access to (e.g. root)', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'LOCK',
path: '/',
actor: {
user: {
id: 1,
uuid: 'test-uuid',
username: 'test',
},
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('rejects a second exclusive lock on the same path', async () => {
const uniquePath = `/double-lock-${Date.now()}.txt`;
const uniquePath = `/test/double-lock-${Date.now()}.txt`;
// First lock
const { res: res1, captured: cap1 } = makeRes();
@@ -582,7 +681,7 @@ describe('WebDAVController', () => {
});
it('refreshes an existing lock when If header provides the token', async () => {
const uniquePath = `/refresh-lock-${Date.now()}.txt`;
const uniquePath = `/test/refresh-lock-${Date.now()}.txt`;
const { res: res1, captured: cap1 } = makeRes();
await dispatchMiddleware(
makeReq({
@@ -27,6 +27,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { toLegacyEntry } from '../fs/legacyFsHelpers.js';
import { PuterController } from '../types.js';
import {
createLock,
@@ -128,7 +129,7 @@ export class WebDAVController extends PuterController {
case 'MOVE':
return this.#move(req, res, actor, davPath, redis, lockToken);
case 'LOCK':
return this.#lock(req, res, davPath, redis, lockToken);
return this.#lock(req, res, actor, davPath, redis, lockToken);
case 'UNLOCK':
return this.#unlock(req, res, davPath, redis);
default:
@@ -646,12 +647,18 @@ export class WebDAVController extends PuterController {
async #lock(
req: Request,
res: Response,
actor: Actor,
davPath: string,
redis: unknown,
headerToken: string | null,
): Promise<void> {
const r = redis as import('ioredis').Cluster;
// ACL must succeed before any lock state is touched — otherwise
// an authenticated user could lock paths they don't own (e.g. `/`)
// and block writes for everyone else.
await this.#assertWrite(actor, davPath);
// Refresh existing lock
if (headerToken) {
const existing = await getLockIfValid(r, headerToken);
@@ -766,19 +773,23 @@ export class WebDAVController extends PuterController {
entry: FSEntry,
extra?: Record<string, unknown>,
): void {
const payload = {
user_id_list: [entry.userId],
response: { ...entry, ...extra, from_new_service: true },
};
const meta = {};
void Promise.resolve()
.then(() =>
.then(async () => {
const response = {
...(await toLegacyEntry(this.clients.event, entry)),
...extra,
from_new_service: true,
};
this.clients.event.emit(
eventName,
payload as unknown as EventMap[T],
{
user_id_list: [entry.userId],
response,
} as unknown as EventMap[T],
meta,
),
)
);
})
.catch(() => {
// non-critical
});
+11 -1
View File
@@ -64,7 +64,7 @@ export const isSystemActor = (actor: Actor | undefined | null): boolean => {
};
export const isAppActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.app && !actor?.accessToken;
return !!actor?.app && !isAccessTokenActor(actor);
};
export const isAccessTokenActor = (
@@ -97,3 +97,13 @@ export const userRelatedActor = (actor: Actor): Actor => {
if (!actor.app && !actor.accessToken) return actor;
return { user: actor.user };
};
/**
* Walk the access-token issuer chain and return the first app identity found,
* or null if no app appears anywhere in the chain.
*/
export const effectiveActorApp = (actor: Actor): ActorApp | null => {
if (actor.app) return actor.app;
if (actor.accessToken) return effectiveActorApp(actor.accessToken.issuer);
return null;
};
+1
View File
@@ -1,5 +1,6 @@
// NOT ALL OF THEM, ADD OLD ONES AS NEEDED, IF NEEDED. DO NOT ADD NEW ONES THOUGH.
export type LegacyErrorCodes =
| 'payment_account_not_set_up'
| 'unknown_error'
| 'disallowed_value'
| 'invalid_token'
@@ -80,6 +80,9 @@ declare global {
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
is_custom_domain?: boolean;
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
cookies?: Record<string, string>;
}
}
}
@@ -73,8 +73,33 @@ interface ReqInit {
query?: Record<string, unknown>;
handshakeQuery?: Record<string, unknown>;
actor?: Actor;
protocol?: string;
}
// Minimal stand-in for `cookie-parser` — splits on `;`, URL-decodes the
// value, and strips a pair of surrounding double quotes (matches the
// `cookie` package's behavior, which `cookie-parser` uses internally).
const parseCookieHeader = (header: string): Record<string, string> => {
const out: Record<string, string> = {};
for (const piece of header.split(';')) {
const eq = piece.indexOf('=');
if (eq < 0) continue;
const name = piece.slice(0, eq).trim();
if (!name) continue;
let value = piece.slice(eq + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
try {
value = decodeURIComponent(value);
} catch {
/* leave as-is */
}
out[name] = value;
}
return out;
};
const makeReq = (init: ReqInit = {}): Request => {
const headers: Record<string, string> = { ...(init.headers ?? {}) };
if (init.cookieHeader) headers.cookie = init.cookieHeader;
@@ -82,11 +107,15 @@ const makeReq = (init: ReqInit = {}): Request => {
body: init.body,
query: (init.query ?? {}) as Request['query'],
headers: headers as unknown as Request['headers'],
protocol: init.protocol,
header(name: string) {
// Express's `req.header()` is case-insensitive; mirror that.
return headers[name.toLowerCase()];
return headers[name.toLowerCase()] as unknown as string[] & string;
},
};
if (init.cookieHeader) {
req.cookies = parseCookieHeader(init.cookieHeader);
}
if (init.actor) req.actor = init.actor;
if (init.handshakeQuery) {
req.handshake = { query: init.handshakeQuery };
@@ -303,10 +332,7 @@ describe('createAuthProbe — cookie reading', () => {
cookieName: 'puter_token',
});
// Quoted + percent-encoded value: `"a b"` → `a b`
await runProbe(
probe,
makeReq({ cookieHeader: 'puter_token="a%20b"' }),
);
await runProbe(probe, makeReq({ cookieHeader: 'puter_token="a%20b"' }));
expect(stub.seenTokens).toEqual(['a b']);
});
@@ -319,6 +345,114 @@ describe('createAuthProbe — cookie reading', () => {
);
expect(stub.seenTokens).toEqual([]);
});
it('ignores session cookies on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
const { req } = await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://attacker.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBeUndefined();
});
it('still accepts bearer tokens on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
authorization: 'Bearer header-tok',
host: 'api.puter.test',
origin: 'https://app.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['header-tok']);
});
it('keeps session cookies for same-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('normalizes default ports when comparing browser origins', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test:443',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('treats protocol mismatches as cross-origin for session cookies', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'http://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
});
});
// ── Behavior when AuthService responds ──────────────────────────────
@@ -348,7 +482,7 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
expect(req.tokenAuthFailed).toBe(true);
});
it("never rejects — sets tokenAuthFailed even when AuthService throws", async () => {
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
const stub = makeStubAuth();
stub.setNext('throw');
const probe = createAuthProbe({ authService: stub.service });
+28 -29
View File
@@ -44,7 +44,7 @@ interface AuthProbeOptions {
* 1. `req.body.auth_token`
* 2. `Authorization: Bearer <token>` header
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
* 4. Session cookie
* 4. Session cookie, only when the browser request is same-origin
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
@@ -126,12 +126,13 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
return stripBearer(xApiKey);
}
// 4. Cookie (set by login flow for session tokens). We parse the
// Cookie header directly rather than depending on `cookie-parser`
// middleware — the probe only needs one named value.
if (cookieName) {
const cookieToken = readCookie(req, cookieName);
if (cookieToken) {
// 4. Cookie (set by login flow for session tokens). Do not let an
// arbitrary browser Origin spend an ambient session cookie against the
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
// available for cross-origin SDK requests.
if (cookieName && !isCrossOriginBrowserRequest(req)) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
}
}
@@ -157,27 +158,25 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
const stripBearer = (t: string): string => t.replace(/^Bearer\s+/i, '').trim();
/**
* Minimal cookie reader. Avoids pulling in `cookie-parser` for the one
* lookup the probe needs. Handles quoted values and URL-decodes the result.
*/
const readCookie = (req: Request, name: string): string | null => {
const header =
typeof req.header === 'function' ? req.header('cookie') : undefined;
if (!header || typeof header !== 'string') return null;
const target = `${name}=`;
for (const rawPair of header.split(';')) {
const pair = rawPair.trim();
if (!pair.startsWith(target)) continue;
let value = pair.slice(target.length);
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
const isCrossOriginBrowserRequest = (req: Request): boolean => {
const origin =
typeof req.header === 'function' ? req.header('origin') : undefined;
if (!origin) return false;
const host =
typeof req.header === 'function' ? req.header('host') : undefined;
if (!host) return true;
const protocol =
typeof req.protocol === 'string' && req.protocol.length > 0
? req.protocol
: undefined;
if (!protocol) return true;
try {
const requestOrigin = new URL(`${protocol}://${host.trim()}`).origin;
return new URL(origin).origin !== requestOrigin;
} catch {
return true;
}
return null;
};
@@ -80,6 +80,11 @@ export const createErrorHandler = (
return;
}
const translated = translateKnownClientError(err);
if (translated) {
err = translated;
}
if (isHttpError(err)) {
opts.onError?.(err, req);
if (err.statusCode === 402 || err.statusCode === 413) {
@@ -102,6 +107,70 @@ export const createErrorHandler = (
};
};
/**
* Recognise framework-level errors that are caused by the client and
* re-shape them as `HttpError`s with `client_*` legacy codes so the
* alarm gate (server.ts) treats them as user-caused 4xx and does not
* page on them.
*
* Sources covered:
* - `URIError` from `decodeURIComponent` in the express router (raised
* by path-traversal scanners hitting `%c0%ae` etc.)
* - body-parser `entity.parse.failed` (malformed JSON in request body)
* - body-parser `request.aborted` / `ECONNABORTED` (client closed
* socket mid-upload)
* - Anything else that already opted into `expose: true` with a
* numeric `statusCode` is mapped to `client_bad_request` so it
* surfaces with the declared status instead of becoming a 500.
*/
const translateKnownClientError = (err: unknown): HttpError | null => {
if (err instanceof URIError) {
return new HttpError(400, 'Bad request URL', {
legacyCode: 'client_bad_url',
});
}
if (!err || typeof err !== 'object') return null;
const e = err as {
type?: string;
code?: string;
statusCode?: number;
status?: number;
expose?: boolean;
message?: string;
};
if (e.type === 'request.aborted' || e.code === 'ECONNABORTED') {
return new HttpError(400, 'Request aborted', {
legacyCode: 'client_aborted',
});
}
if (e.type === 'entity.parse.failed') {
return new HttpError(400, 'Malformed JSON in request body', {
legacyCode: 'client_bad_json',
});
}
// Generic body-parser / http-errors convention: anything tagged
// `expose: true` with a real 4xx statusCode is by definition meant
// to be returned to the client, not paged on. Honour the declared
// status; tag with a known code so the alarm gate skips it.
const declaredStatus = e.statusCode ?? e.status;
if (
e.expose === true &&
typeof declaredStatus === 'number' &&
declaredStatus >= 400 &&
declaredStatus < 500
) {
return new HttpError(declaredStatus, e.message ?? 'Bad request', {
legacyCode: 'client_bad_request',
});
}
return null;
};
const serializeHttpError = (err: HttpError): Record<string, unknown> => {
const payload: Record<string, unknown> = {
error: err.message,
+21
View File
@@ -114,6 +114,27 @@ export const requireUserActorGate = (): RequestHandler => {
};
};
export const requireNonAccessTokenGate = (): RequestHandler => {
return (req, _res, next) => {
const actor = req.actor;
if (!actor) {
next(rejectAuth(req));
return;
}
if (actor.accessToken) {
next(
new HttpError(
403,
'Access tokens are not allowed to access this resource',
{ legacyCode: 'forbidden' },
),
);
return;
}
next();
};
};
/** Built-in admin usernames that always pass `adminOnly`. */
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
@@ -21,6 +21,7 @@ import type { RequestHandler } from 'express';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import type { IConfig } from '../../../types';
import { assertNormalized } from '../../../services/fs/resolveNode.js';
/** Native-app subdomains served via `nativeAppStatic`. */
const NATIVE_APP_SUBDOMAINS = [
@@ -130,10 +131,8 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
? path.join(root, active, 'dist')
: path.join(root, active);
// req.path is already url-decoded by express; normalize strips any
// `..` segments before sendFile's `root` option enforces its own
// traversal guard.
const requested = path.normalize(req.path);
const requested = req.path;
assertNormalized(requested);
const absolute = path.join(appRoot, requested);
try {
@@ -28,12 +28,13 @@ import {
buildAppCenterFallback,
buildHostingConfig,
buildPrivateHostRedirect,
buildPublicHostRedirect,
getBootstrapToken,
hostMatchesPrivateDomain,
normalizeHost,
normalizeHostRaw,
renderLoginBootstrapHtml,
resolvePrivateAppForHostedSite,
resolveOwnedAppForHostedSite,
resolvePrivateIdentity,
resolvePublicHostedIdentity,
subdomainFromHost,
@@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => {
});
});
describe('buildPublicHostRedirect', () => {
// Mirror of buildPrivateHostRedirect — swaps the private hosting
// domain for the public one. Used when a non-private app (or no app
// at all) hits the private host, so a paid-→-free app's old
// `puter.app` URL still resolves on `puter.site`.
const cfg = buildHostingConfig({
domain: 'puter.localhost',
static_hosting_domain: 'site.puter.localhost:4100',
static_hosting_domain_alt: null,
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: null,
protocol: 'http',
} as unknown as IConfig);
const reqOf = (init: Partial<Request>): Request =>
({
hostname: init.hostname,
originalUrl: init.originalUrl,
protocol: init.protocol ?? 'http',
headers: init.headers ?? {},
}) as unknown as Request;
it('swaps the private hosting domain for the public one (preserving port + path + query)', () => {
const url = buildPublicHostRedirect(
reqOf({
hostname: 'beans.app.puter.localhost',
originalUrl: '/some/path?x=1',
}),
cfg,
);
expect(url).toBe(
'http://beans.site.puter.localhost:4100/some/path?x=1',
);
});
it("defaults the path to '/' when originalUrl is empty", () => {
const url = buildPublicHostRedirect(
reqOf({ hostname: 'beans.app.puter.localhost' }),
cfg,
);
expect(url).toBe('http://beans.site.puter.localhost:4100/');
});
it('returns null when no public hosting domain is configured', () => {
const noPublic = {
...cfg,
staticDomains: [],
staticDomainsRaw: [],
};
expect(
buildPublicHostRedirect(
reqOf({ hostname: 'beans.app.puter.localhost' }),
noPublic,
),
).toBeNull();
});
it('returns null for the bare private host (no subdomain to forward)', () => {
expect(
buildPublicHostRedirect(
reqOf({ hostname: 'app.puter.localhost' }),
cfg,
),
).toBeNull();
});
});
// ── Login bootstrap HTML ────────────────────────────────────────────
describe('renderLoginBootstrapHtml', () => {
@@ -383,7 +451,7 @@ describe('renderLoginBootstrapHtml', () => {
// ── Server-backed helpers (AuthService + DB) ─────────────────────────
//
// `resolvePrivateAppForHostedSite` queries the apps table directly;
// `resolveOwnedAppForHostedSite` queries the apps table directly;
// `resolvePrivateIdentity` / `resolvePublicHostedIdentity` rely on the
// real AuthService for token verification + cookie naming.
@@ -456,7 +524,33 @@ const baseConfig = () =>
protocol: 'http',
} as unknown as IConfig);
describe('resolvePrivateAppForHostedSite', () => {
const createApp = async (
ownerId: number,
indexUrl: string,
opts: { isPrivate?: boolean } = {},
): Promise<{ id: number; uid: string }> => {
const uid = `app-${uuidv4()}`;
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?)`,
[
uid,
`app-${uid}`,
`app-${uid}`,
indexUrl,
ownerId,
opts.isPrivate ? 1 : 0,
],
);
const row = (
await server.clients.db.read('SELECT id, uid FROM apps WHERE uid = ?', [
uid,
])
)[0] as { id: number; uid: string };
return row;
};
describe('resolveOwnedAppForHostedSite', () => {
const reqOf = (host: string): Request =>
({
hostname: host,
@@ -464,84 +558,101 @@ describe('resolvePrivateAppForHostedSite', () => {
headers: { host },
}) as unknown as Request;
it('returns the associated app immediately when it is already private', async () => {
it("matches the subdomain owner's app by index_url on the same hosting domain", async () => {
const owner = await makeUser();
const associatedApp = {
id: 1,
uid: 'app-direct',
is_private: 1,
owner_user_id: owner.id,
};
const out = await resolvePrivateAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: owner.id, associated_app_id: 1 },
associatedApp,
db: server.clients.db,
config: baseConfig(),
matchedHostingDomain: 'site.puter.localhost',
});
expect(out?.uid).toBe('app-direct');
});
it('falls back to an index_url lookup when associated_app_id is unset', async () => {
const owner = await makeUser();
// Private app registered with a URL on the static hosting domain.
// The visitor lands on the equivalent private host — the helper
// should still find it.
const app = await createPrivateApp(
owner.id,
'http://beans.site.puter.localhost/',
);
const out = await resolvePrivateAppForHostedSite({
req: reqOf('beans.app.puter.localhost'),
site: { user_id: owner.id, associated_app_id: null },
associatedApp: null,
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
matchedHostingDomain: 'app.puter.localhost',
});
expect(out?.uid).toBe(app.uid);
});
it('returns the original associated app when no index_url matches', async () => {
it('matches across hosting variants (static URL, private host request)', async () => {
const owner = await makeUser();
const associatedApp = {
id: 99,
uid: 'app-public',
is_private: 0,
owner_user_id: owner.id,
};
const out = await resolvePrivateAppForHostedSite({
req: reqOf('orphan.site.puter.localhost'),
site: { user_id: owner.id, associated_app_id: 99 },
associatedApp,
const app = await createPrivateApp(
owner.id,
'http://beans.site.puter.localhost/',
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.app.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
matchedHostingDomain: 'site.puter.localhost',
});
expect(out?.uid).toBe('app-public');
expect(out?.uid).toBe(app.uid);
});
it("ignores apps owned by a different user even when index_url matches the host", async () => {
// Regression test for the `associated_app_uid` IDOR: an app owned
// by user B must never resolve as "associated" with a subdomain
// owned by user A, even when the index_url notionally matches.
const a = await makeUser();
const b = await makeUser();
await createPrivateApp(b.id, 'http://beans.site.puter.localhost/');
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: a.id },
db: server.clients.db,
config: baseConfig(),
});
expect(out).toBeNull();
});
it('returns a public app when requirePrivate is not set', async () => {
const owner = await makeUser();
const app = await createApp(
owner.id,
'http://beans.site.puter.localhost/',
{ isPrivate: false },
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
});
expect(out?.uid).toBe(app.uid);
});
it('filters out non-private apps when requirePrivate is set', async () => {
const owner = await makeUser();
await createApp(
owner.id,
'http://beans.site.puter.localhost/',
{ isPrivate: false },
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
requirePrivate: true,
});
expect(out).toBeNull();
});
it('returns null when the site has no owner', async () => {
const out = await resolvePrivateAppForHostedSite({
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
site: { user_id: null, associated_app_id: null },
associatedApp: null,
site: { user_id: null },
db: server.clients.db,
config: baseConfig(),
matchedHostingDomain: 'site.puter.localhost',
});
expect(out).toBeNull();
});
it("returns null on a request whose host can't be parsed", async () => {
const out = await resolvePrivateAppForHostedSite({
const out = await resolveOwnedAppForHostedSite({
req: reqOf(''),
site: { user_id: 1, associated_app_id: null },
associatedApp: null,
site: { user_id: 1 },
db: server.clients.db,
config: baseConfig(),
matchedHostingDomain: 'site.puter.localhost',
});
expect(out).toBeNull();
});
@@ -564,7 +675,7 @@ describe('resolvePrivateIdentity', () => {
it('returns the sticky private-cookie identity when the token matches the expected app/subdomain/host', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const token = authService.createPrivateAssetToken({
const token = await authService.createPrivateAssetToken({
appUid,
userUid: user.uuid,
subdomain: 'beans',
@@ -589,7 +700,7 @@ describe('resolvePrivateIdentity', () => {
it('falls through to req.actor when the private cookie is for a different app', async () => {
const user = await makeUser();
const wrongToken = authService.createPrivateAssetToken({
const wrongToken = await authService.createPrivateAssetToken({
appUid: `app-${uuidv4()}`,
userUid: user.uuid,
subdomain: 'beans',
@@ -627,6 +738,124 @@ describe('resolvePrivateIdentity', () => {
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
it("ignores req.actor whose actor.app.uid 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
// entitlement to the target app, accepting the actor would let
// attacker-app JS replay a victim's app-under-user token against
// an unrelated private host.
const user = await makeUser();
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: {
user: { uuid: user.uuid },
app: { uid: `app-attacker-${uuidv4()}` },
session: { uid: 'sess-1' },
},
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: `app-target-${uuidv4()}`,
});
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
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
// app_uid=attacker; using it as a bootstrap on the target host
// must fall through to source='none' (login bootstrap), not
// promote it to a victim identity on the target app. Both app
// rows are seeded so authenticateFromToken returns a real actor
// — proving the guard (not a missing row) is what blocks it.
const user = await makeUser();
// App uids must be `app-<hex-uuid>` — TokenService compresses the
// `app_uid` field by stripping the `app-` prefix and decoding the
// rest as a hex-encoded UUID (see TokenService AUTH_COMPRESSION).
// Custom non-uuid suffixes round-trip incorrectly and break
// authenticateFromToken's app lookup.
const attackerUid = `app-${uuidv4()}`;
const targetUid = `app-${uuidv4()}`;
// index_url is NOT NULL in the schema, so supply placeholders.
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`,
[
attackerUid,
`attacker-${attackerUid}`,
`attacker-${attackerUid}`,
`http://${attackerUid}.example/`,
user.id,
0,
targetUid,
`target-${targetUid}`,
`target-${targetUid}`,
`http://${targetUid}.example/`,
user.id,
1,
],
);
const wrongAppToken = await authService.getUserAppToken(
{ user: { id: user.id, uuid: user.uuid } } as unknown as Parameters<
typeof authService.getUserAppToken
>[0],
attackerUid,
);
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
query: { 'puter.auth.token': wrongAppToken },
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: targetUid,
});
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
it('accepts a bootstrap query-token whose actor.app.uid matches the target app', async () => {
// Positive case: a token correctly bound to the target app
// resolves the user identity. Confirms the guard isn't blocking
// legitimate same-app traffic.
const user = await makeUser();
// App uids must be `app-<hex-uuid>` for token round-trip — see the
// negative-case comment above.
const targetUid = `app-${uuidv4()}`;
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?)`,
[
targetUid,
`target-${targetUid}`,
`target-${targetUid}`,
`http://${targetUid}.example/`,
user.id,
1,
],
);
const matchedToken = await authService.getUserAppToken(
{ user: { id: user.id, uuid: user.uuid } } as unknown as Parameters<
typeof authService.getUserAppToken
>[0],
targetUid,
);
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
query: { 'puter.auth.token': matchedToken },
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: targetUid,
});
expect(out.source).toBe('query');
expect(out.userUid).toBe(user.uuid);
});
});
describe('resolvePublicHostedIdentity', () => {
@@ -644,7 +873,7 @@ describe('resolvePublicHostedIdentity', () => {
it('returns the cookie identity when present and valid', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const token = authService.createPublicHostedActorToken({
const token = await authService.createPublicHostedActorToken({
appUid,
userUid: user.uuid,
subdomain: 'beans',
@@ -17,9 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient';
import type { Request } from 'express';
import type { AuthService } from '../../../services/auth/AuthService';
import type { IConfig } from '../../../types';
import type { Actor } from '../../actor';
/**
* Support helpers for the private-app access gate — ported from v1's
@@ -29,9 +31,10 @@ import type { IConfig } from '../../../types';
*
* Covers:
* - Host/subdomain parsing against `private_app_hosting_domain(_alt)`.
* - Broader private-app detection for hosted sites whose subdomain row
* has no `associated_app_id` — falls back to an `index_url` lookup
* against the subdomain owner's private apps.
* - Owned-app detection for hosted sites: matches the subdomain owner's
* apps by `index_url` against the request host. Replaces the older
* `associated_app_id`-trusting path, which was user-writable without
* an ownership check.
* - Bootstrap-token identity resolution (`Authorization: Bearer`,
* `?puter.auth.token=`, `X-Puter-Auth-Token`, referrer query) so
* private-app visitors with a valid session token (but no cookie on
@@ -80,25 +83,18 @@ export interface PrivateIdentity {
interface SubdomainLike {
user_id?: number | null;
associated_app_id?: number | null;
}
interface AppLike {
id?: number;
uid?: string;
name?: string;
title?: string;
owner_user_id?: number;
is_private?: boolean | number | null;
index_url?: string | null;
}
interface DBClient {
read: (
sql: string,
params: unknown[],
) => Promise<Record<string, unknown>[]>;
}
// ── Host helpers ────────────────────────────────────────────────────
export function normalizeHost(value: string | undefined | null): string | null {
@@ -173,43 +169,39 @@ export function subdomainFromHost(
return host.split('.')[0] || '';
}
// ── Private-app detection fallback (v1 `resolvePrivateAppForHostedSite`)
// ── Hosted-site → owned-app resolution ─────────────────────────────
/**
* When the subdomain row has no `associated_app_id`, v1 looked up the
* owner's private apps and matched `index_url` against the request host
* variants (this-subdomain × every hosting domain). Ports that logic.
* Resolve "what app does the subdomain owner run on this host?" by matching
* apps owned by `site.user_id` against the request's host variants. Used by
* the puter-site middleware to decide whether the request hits a private
* app (gate) or a public app (sticky cookie), and by the subdomain driver
* to populate the `associated_app` field in API responses.
*
* Returns the matched private app (if any) so the caller can run the
* normal access check — closes a gap where a private app's files could
* be served via a subdomain whose row wasn't explicitly linked.
* Ownership-anchored on `apps.owner_user_id = site.user_id` so a subdomain
* can never "claim" an app belonging to a different user. The `subdomains`
* row's own `associated_app_id` column is intentionally ignored — it was
* previously user-writable without an ownership check, so any value there
* is either legacy or planted; deriving from `index_url` makes the answer
* fall out of facts the system already verifies at app-create time.
*/
export async function resolvePrivateAppForHostedSite(opts: {
export async function resolveOwnedAppForHostedSite(opts: {
req: Request;
site: SubdomainLike;
associatedApp: AppLike | null;
db: DBClient;
db: AbstractDatabaseClient;
config: PrivateHostingConfig;
matchedHostingDomain: string;
requirePrivate?: boolean;
}): Promise<AppLike | null> {
// When the subdomain row's own `associated_app_id` points at a private
// app, that wins outright. Otherwise fall through to index_url matching
// so a private app whose canonical URL lives on one hosting variant
// (`beans.puter.site`) still resolves when the visitor hits another
// (`beans.puter.app`).
if (opts.associatedApp && Number(opts.associatedApp.is_private ?? 0) > 0) {
return opts.associatedApp;
}
if (!opts.site?.user_id) return opts.associatedApp ?? null;
if (!opts.site?.user_id) return null;
const host = normalizeHost(opts.req.hostname);
if (!host) return opts.associatedApp ?? null;
if (!host) return null;
const hostedSubdomain = subdomainFromHost(host, [
...opts.config.staticDomains,
...opts.config.privateDomains,
]);
if (!hostedSubdomain) return opts.associatedApp ?? null;
if (!hostedSubdomain) return null;
// Build host variants with AND without port, then cross each with both
// protocols. Apps store whatever URL the user typed at create time, so
@@ -248,16 +240,17 @@ export async function resolvePrivateAppForHostedSite(opts: {
}
}
const uniqueCandidates = [...new Set(urlCandidates)];
if (uniqueCandidates.length === 0) return opts.associatedApp ?? null;
if (uniqueCandidates.length === 0) return null;
const privateFilter = opts.requirePrivate ? 'AND `is_private` = 1 ' : '';
const placeholders = uniqueCandidates.map(() => '?').join(', ');
const rows = await opts.db.read(
`SELECT * FROM apps WHERE owner_user_id = ? AND is_private = 1 AND index_url IN (${placeholders}) LIMIT 2`,
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
[opts.site.user_id, ...uniqueCandidates],
);
if (rows.length === 0) return opts.associatedApp ?? null;
if (rows.length === 0) return null;
if (rows.length > 1) {
console.warn('[puter-site] private_access.host_match_ambiguous', {
console.warn('[puter-site] hosted_site_app_match_ambiguous', {
requestHost: host,
matchCount: rows.length,
});
@@ -381,7 +374,10 @@ export async function resolvePrivateIdentity(opts: {
// 2. Auth probe actor.
const existingActor = req.actor;
if (existingActor?.user?.uuid) {
if (
existingActor?.user?.uuid &&
actorMatchesExpectedApp(existingActor, expectedAppUid)
) {
return {
source: 'session-cookie',
userUid: existingActor.user.uuid,
@@ -397,7 +393,10 @@ export async function resolvePrivateIdentity(opts: {
if (sessionToken) {
try {
const actor = await authService.authenticateFromToken(sessionToken);
if (actor?.user?.uuid) {
if (
actor?.user?.uuid &&
actorMatchesExpectedApp(actor, expectedAppUid)
) {
return {
source: 'session-cookie',
userUid: actor.user.uuid,
@@ -416,7 +415,10 @@ export async function resolvePrivateIdentity(opts: {
const actor = await authService.authenticateFromToken(
bootstrap.token,
);
if (actor?.user?.uuid) {
if (
actor?.user?.uuid &&
actorMatchesExpectedApp(actor, expectedAppUid)
) {
return {
source: bootstrap.source,
userUid: actor.user.uuid,
@@ -431,6 +433,25 @@ export async function resolvePrivateIdentity(opts: {
return { source: 'none' };
}
/**
* Guard against token confusion across private-app boundaries: an actor
* derived from an app-under-user token carries the *issuing* app's uid,
* which must 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.
*/
function actorMatchesExpectedApp(
actor: Actor,
expectedAppUid: string | undefined,
): boolean {
if (!expectedAppUid) return true;
if (!actor.app?.uid) return true;
return actor.app.uid === expectedAppUid;
}
/**
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the
* sticky `puter.public.hosted.actor.token` cookie first, then the same
@@ -483,7 +504,10 @@ export async function resolvePublicHostedIdentity(opts: {
}
const existingActor = req.actor;
if (existingActor?.user?.uuid) {
if (
existingActor?.user?.uuid &&
actorMatchesExpectedApp(existingActor, expectedAppUid)
) {
return {
source: 'session-cookie',
userUid: existingActor.user.uuid,
@@ -498,7 +522,10 @@ export async function resolvePublicHostedIdentity(opts: {
if (sessionToken) {
try {
const actor = await authService.authenticateFromToken(sessionToken);
if (actor?.user?.uuid) {
if (
actor?.user?.uuid &&
actorMatchesExpectedApp(actor, expectedAppUid)
) {
return {
source: 'session-cookie',
userUid: actor.user.uuid,
@@ -516,7 +543,10 @@ export async function resolvePublicHostedIdentity(opts: {
const actor = await authService.authenticateFromToken(
bootstrap.token,
);
if (actor?.user?.uuid) {
if (
actor?.user?.uuid &&
actorMatchesExpectedApp(actor, expectedAppUid)
) {
return {
source: bootstrap.source,
userUid: actor.user.uuid,
@@ -564,6 +594,38 @@ export function buildPrivateHostRedirect(
void app; // reserved for future use (logging)
}
/**
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
* equivalent URL for a request that landed on the private hosting domain
* but doesn't belong there (non-private app, or no app at all). Used so a
* formerly-paid app that's now free (or a plain hosted site) resolves on
* `puter.site` instead of 404ing on `puter.app`.
*/
export function buildPublicHostRedirect(
req: Request,
config: PrivateHostingConfig,
): string | null {
const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0];
if (!publicDomain) return null;
const host = normalizeHost(req.hostname);
if (!host) return null;
const subdomain = subdomainFromHost(host, [
...config.staticDomains,
...config.privateDomains,
]);
if (!subdomain) return null;
try {
const protocol = config.protocol || req.protocol || 'https';
const base = `${protocol}://${subdomain}.${publicDomain}`;
const reqPath = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
return new URL(reqPath, base).toString();
} catch {
return null;
}
}
/** Redirect URL when private access is denied — lands on the app-center listing. */
export function buildAppCenterFallback(
app: AppLike,
@@ -114,6 +114,7 @@ const makeRes = () => {
const makeReq = (init: {
hostname: string;
path?: string;
originalUrl?: string;
protocol?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
@@ -121,6 +122,9 @@ const makeReq = (init: {
({
hostname: init.hostname,
path: init.path ?? '/',
// Redirect helpers use originalUrl (preserves query string).
// Default to `path` when caller doesn't care to distinguish.
originalUrl: init.originalUrl ?? init.path ?? '/',
protocol: init.protocol ?? 'http',
headers: init.headers ?? {},
cookies: init.cookies ?? {},
@@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => {
});
});
// ── Private hosting domain refusal ──────────────────────────────────
// ── Private hosting domain → public-host redirect ───────────────────
describe('createPuterSiteMiddleware — private hosting domain', () => {
it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => {
it('302s a subdomain on the private host with no private app to the equivalent puter.site URL (covers freed-paid-app bookmarks + plain hosted sites)', async () => {
// Owner exists, subdomain exists, but it has no associated
// private app. On the *private* host this must refuse, not serve.
// private app. On the *private* host this used to 404; now it
// mirrors the public→private redirect so a paid app whose price
// dropped to 0 still resolves on `puter.site` when accessed via
// its old `puter.app` URL.
const owner = await makeUser();
const sub = `leak-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
@@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
makeReq({
// Note: app.puter.localhost is the *private* hosting domain.
hostname: `${sub}.app.puter.localhost`,
path: '/some/deep/path.html',
}),
);
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Subdomain not found');
expect(out.redirected).toEqual({
status: 302,
url: `http://${sub}.site.puter.localhost/some/deep/path.html`,
});
});
it('uses the alt private hosting domain when configured', async () => {
it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => {
// Coverage for the `private_app_hosting_domain_alt` slot — same
// refusal logic, but via the alternate host that the deployment
// redirect logic, but via the alternate host that the deployment
// can use for legacy traffic.
const owner = await makeUser();
const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`;
@@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
mw,
makeReq({ hostname: `${sub}.apps.alt.localhost` }),
);
expect(out.redirected).toEqual({
status: 302,
url: `http://${sub}.site.puter.localhost/`,
});
});
it('falls back to 404 when no public hosting domain is configured (no leak)', async () => {
// Without a static_hosting_domain to redirect to we have no safe
// target; the original refusal must still apply so a public-app
// subdomain doesn't accidentally serve via the private host.
const owner = await makeUser();
const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
});
const mw = createPuterSiteMiddleware(
{
...hostingConfig,
static_hosting_domain: null,
} as unknown as IConfig,
{
clients: server.clients,
stores: server.stores,
services: server.services,
},
);
const { out } = await runMiddleware(
mw,
makeReq({ hostname: `${sub}.app.puter.localhost` }),
);
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Subdomain not found');
expect(out.redirected).toBeUndefined();
});
});
@@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('<html>hi</html>');
await writeFile(
owner.id,
`${homePath}/index.html`,
body,
'text/html',
);
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('default doc');
await writeFile(
owner.id,
`${homePath}/index.html`,
body,
'text/html',
);
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -597,7 +629,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(String(out.body)).toContain('Not Found');
});
it('returns 404 when the resolved URL points at a directory rather than a file', async () => {
it('returns 404 when the resolved URL points at a directory with no index.html', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
@@ -613,8 +645,8 @@ describe('createPuterSiteMiddleware — file serving', () => {
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
// Documents/ exists from generateDefaultFsentries — a
// directory entry, which the file branch must refuse.
// Documents/ exists from generateDefaultFsentries but has
// no index.html — directory fallback must still 404.
path: '/Documents',
}),
res,
@@ -625,6 +657,45 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(out.contentType).toBe('text/html; charset=UTF-8');
});
it('serves <folder>/index.html when the URL resolves to a folder containing one', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `folderidx-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
const body = Buffer.from('nested doc');
await writeFile(
owner.id,
`${homePath}/Documents/index.html`,
body,
'text/html',
);
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
// No trailing slash; folder exists and contains index.html.
path: '/Documents',
}),
res,
vi.fn(),
);
// Allow the piped stream to flush.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Type']).toMatch(/text\/html/);
const piped = out.body as Buffer | undefined;
expect(Buffer.isBuffer(piped)).toBe(true);
expect(piped!.equals(body)).toBe(true);
});
it("serves the HTML SUBDOMAIN_404 when the subdomain's root_dir_id points to a missing entry", async () => {
// Subdomain row references a fsentry id that doesn't exist —
// earlier path resolution must catch this with the HTML 404 page.
@@ -657,11 +728,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
const body = Buffer.from('not a directory');
// Write a file and use ITS id as the subdomain's root_dir_id —
// the middleware must reject because root must be a directory.
await writeFile(
owner.id,
`${homePath}/Documents/somefile.txt`,
body,
);
await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`${homePath}/Documents/somefile.txt`,
);
@@ -733,12 +800,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('inside');
await writeFile(
owner.id,
`${homePath}/safe.txt`,
body,
'text/plain',
);
await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -758,3 +820,344 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(out.statusCode).toBe(404);
});
});
// ── .puter_site_config (custom error pages) ─────────────────────────
//
// Sites can drop a `.puter_site_config` JSON file at the root of their
// hosting directory to map error codes onto custom pages — the
// canonical use case is SPA fallback (404 → /index.html with status
// 200). These tests pin the contract end-to-end through the real
// FSService so we exercise parsing, path resolution, and the loop-
// prevention guard together.
describe('createPuterSiteMiddleware — .puter_site_config', () => {
const setupSiteWithConfig = async (config: unknown) => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `cfg-${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}/.puter_site_config`,
Buffer.from(JSON.stringify(config)),
'application/json',
);
return { owner, homePath, sub };
};
it('serves /index.html with status 200 on 404 when configured (SPA fallback)', async () => {
// The user's headline use case: route any unknown path through
// the SPA entrypoint so client-side routing can take over,
// while still serving HTTP 200 so search engines don't cache
// the page as a hard 404.
const { owner, homePath, sub } = await setupSiteWithConfig({
errors: {
'404': { file: '/index.html', status: 200 },
},
});
const body = Buffer.from('<html>spa-shell</html>');
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/some/client-route',
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Type']).toMatch(/text\/html/);
const piped = out.body as Buffer | undefined;
expect(Buffer.isBuffer(piped)).toBe(true);
expect(piped!.equals(body)).toBe(true);
});
it('serves the configured file with the matched code when `status` is omitted', async () => {
// No explicit `status` → default to the matched error code
// (404 here). Bare `{ file: '/404.html' }` should Just Work
// for the classic "pretty 404 page" use case.
const { owner, homePath, sub } = await setupSiteWithConfig({
errors: { '404': { file: '/404.html' } },
});
const body = Buffer.from('<html>custom 404</html>');
await writeFile(owner.id, `${homePath}/404.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/no-such-page',
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(404);
const piped = out.body as Buffer | undefined;
expect(piped!.equals(body)).toBe(true);
});
it('falls back to the default 404 page when the configured error file does not exist (no infinite loop)', async () => {
// Critical loop guard: if the error page itself is missing,
// we must NOT recurse through the error config again. The
// request must terminate with the built-in 404 page rather
// than spinning errors.404 → errors.404 → … .
const { sub } = await setupSiteWithConfig({
errors: { '404': { file: '/missing.html', status: 200 } },
});
// Deliberately do not write missing.html.
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/anything',
}),
res,
vi.fn(),
);
expect(out.statusCode).toBe(404);
expect(out.contentType).toBe('text/html; charset=UTF-8');
expect(String(out.body)).toContain('Not Found');
});
it('returns 404 when a visitor requests `.puter_site_config` directly (no config leak)', async () => {
// The config file is implementation detail — hide it the same
// way any other missing path would 404, so visitors can't
// enumerate deployment shape by guessing well-known names.
const { sub } = await setupSiteWithConfig({
errors: { '404': { file: '/index.html', status: 200 } },
});
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/.puter_site_config',
}),
res,
vi.fn(),
);
// No error-page fallback because we didn't write /index.html.
expect(out.statusCode).toBe(404);
// Body shape is the default 404, not the JSON config.
expect(String(out.body)).not.toContain('errors');
});
it('rejects `errors.404.file` paths that try to climb out of the site root', async () => {
// The normalizer collapses `..` segments, so an attacker who
// can edit the config can't pivot it into reading files
// outside their own subdomain root.
const otherOwner = await makeUserWithHome();
const secretPath = `/${otherOwner.username}/secret.html`;
await writeFile(
otherOwner.id,
secretPath,
Buffer.from('SECRET'),
'text/html',
);
const { sub } = await setupSiteWithConfig({
errors: {
'404': {
// Tries to walk up to the other user's home dir.
file: `/../${otherOwner.username}/secret.html`,
status: 200,
},
},
});
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/anything',
}),
res,
vi.fn(),
);
// The collapsed path resolves under the requesting site's
// root, which doesn't have a `<username>/secret.html` file —
// so the fallback fails and we get the default 404, NOT the
// SECRET body.
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('SECRET');
});
it('ignores malformed JSON configs and behaves like there is no config', async () => {
// A typo in the config file must not 5xx the request — the
// visitor still gets the default 404 on a missing path.
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `bad-${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}/.puter_site_config`,
Buffer.from('this is not json {{{'),
'application/json',
);
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('would-be-fallback'),
'text/html',
);
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/unknown',
}),
res,
vi.fn(),
);
expect(out.statusCode).toBe(404);
expect(String(out.body)).toContain('Not Found');
// Did NOT silently fall back to /index.html as if the config
// were valid — malformed config must be ignored, not partially
// applied.
expect(String(out.body)).not.toContain('would-be-fallback');
});
it('still serves real files normally — config only applies on 404', async () => {
// Sanity check: when the requested path exists, the config's
// error rules are not consulted, so the response is the live
// file at status 200 (not the error-page status override).
const { owner, homePath, sub } = await setupSiteWithConfig({
errors: { '404': { file: '/index.html', status: 200 } },
});
const body = Buffer.from('real-page');
await writeFile(
owner.id,
`${homePath}/page.html`,
body,
'text/html',
);
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/page.html',
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
const piped = out.body as Buffer | undefined;
expect(piped!.equals(body)).toBe(true);
});
it('serves the cached config on a second request, even when the on-disk file is removed (Redis cache holds within TTL)', async () => {
// First request seeds the cache with the parsed config. We
// then delete the underlying file and fire a second request:
// if the cache is wired correctly, the SPA fallback still
// applies because we never re-read from S3. Within-TTL
// staleness is the explicit contract (60s default).
const { owner, homePath, sub } = await setupSiteWithConfig({
errors: { '404': { file: '/index.html', status: 200 } },
});
const body = Buffer.from('<html>spa-shell</html>');
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
// First request → primes the cache.
const first = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/route-a',
}),
first.res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(first.out.statusCode).toBe(200);
// Now wipe the on-disk config. If the loader hits S3 on every
// request, the second call below would see no config and fall
// through to a default 404. The cache contract is that it
// does NOT — within the TTL, the prior parse stands.
const configEntry = await server.stores.fsEntry.getEntryByPath(
`${homePath}/.puter_site_config`,
);
if (configEntry) {
await server.services.fs.remove(owner.id, { entry: configEntry });
}
const second = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/route-b',
}),
second.res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
// SPA fallback still fires → cache served the deleted config.
expect(second.out.statusCode).toBe(200);
expect(
(second.out.body as Buffer | undefined)?.equals(body),
).toBe(true);
});
it('ignores `errors` entries with status codes outside 4xx/5xx', async () => {
// A `200` key in errors is meaningless and a footgun (it
// could be used to silently override the happy path). The
// parser must drop these on the floor.
const { sub } = await setupSiteWithConfig({
errors: {
'200': { file: '/oops.html', status: 200 },
'999': { file: '/oops.html', status: 200 },
},
});
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/missing',
}),
res,
vi.fn(),
);
// No valid 404 rule survives the filter → default 404.
expect(out.statusCode).toBe(404);
expect(String(out.body)).toContain('Not Found');
});
});
+147 -41
View File
@@ -21,20 +21,27 @@ import type { RequestHandler } from 'express';
import { contentType as contentTypeFromMime } from 'mime-types';
import { posix as pathPosix } from 'node:path';
import type { puterClients } from '../../../clients';
import { FS_COSTS } from '../../../controllers/fs/costs';
import type { puterServices } from '../../../services';
import type { puterStores } from '../../../stores';
import { FS_COSTS } from '../../../controllers/fs/costs';
import type { IConfig, LayerInstances } from '../../../types';
import {
buildAppCenterFallback,
buildHostingConfig,
buildPrivateHostRedirect,
buildPublicHostRedirect,
hostMatchesPrivateDomain,
renderLoginBootstrapHtml,
resolvePrivateAppForHostedSite,
resolveOwnedAppForHostedSite,
resolvePrivateIdentity,
resolvePublicHostedIdentity,
} from './privateAppGate';
import {
isSiteConfigPath,
loadSiteConfig,
resolveErrorTarget,
type SiteConfig,
} from './puterSiteConfig';
/**
* Serves user-hosted static sites on the hosting domains (`*.puter.site`,
@@ -51,9 +58,15 @@ import {
*
* Deferred (not yet implemented):
* - `.at` username-based sites (UUIDv5-keyed `/user/Public`).
* - `.puter_site_config` error rules (custom status-code → file mapping).
* - Custom domains (subdomains table `domain` column) — requires host
* validation to allow arbitrary hostnames first.
*
* Site config:
* - `.puter_site_config` at the site root (see `puterSiteConfig.ts`)
* supplies custom error pages. On a file 404, the matching rule's
* `file` is served with the rule's `status` — supports the SPA
* fallback pattern of `404 → /index.html with status 200`. The
* config file itself is hidden from public serving.
*/
const SUBDOMAIN_404 = `<div style="font-size: 20px;
@@ -191,29 +204,21 @@ export const createPuterSiteMiddleware = (
const hostingCfg = buildHostingConfig(config);
let associatedApp: AppRow | null = null;
if (
site.associated_app_id !== null &&
site.associated_app_id !== undefined
) {
associatedApp = (await layers.stores.app.getById(
site.associated_app_id,
)) as unknown as AppRow | null;
}
const privateApp = (await resolvePrivateAppForHostedSite({
// The subdomain row's `associated_app_id` column is intentionally
// not consulted — it was previously user-writable without an
// ownership check (so any value there is either legacy or planted).
// Resolve "what app does this subdomain host?" directly from
// `apps.owner_user_id = site.user_id` + `index_url` match against
// the request host.
const associatedApp = (await resolveOwnedAppForHostedSite({
req,
site: {
user_id: site.user_id,
associated_app_id: site.associated_app_id,
},
associatedApp,
site: { user_id: site.user_id },
db: layers.clients.db,
config: hostingCfg,
matchedHostingDomain: matched,
})) as AppRow | null;
const isPrivateApp = Boolean(privateApp?.is_private);
const isPrivateApp = Boolean(associatedApp?.is_private);
const privateApp = isPrivateApp ? associatedApp : null;
if (isPrivateApp) {
// Private apps must run on the private hosting domain. If a
@@ -318,13 +323,14 @@ export const createPuterSiteMiddleware = (
// visitors but still refreshes after rotation/expiry.
if (!identity.hasValidPrivateCookie) {
try {
const token = layers.services.auth.createPrivateAssetToken({
appUid: privateApp!.uid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain,
privateHost: host,
});
const token =
await layers.services.auth.createPrivateAssetToken({
appUid: privateApp!.uid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain,
privateHost: host,
});
res.cookie(
layers.services.auth.getPrivateAssetCookieName(),
token,
@@ -344,8 +350,20 @@ export const createPuterSiteMiddleware = (
// third-party resources loaded from the app.
res.setHeader('Referrer-Policy', 'no-referrer');
} else if (privateHostingDomains.has(matched)) {
// Private host with no private app → refuse. Prevents a
// public-app subdomain from leaking via the private host.
// Non-private content landed on the private hosting domain —
// mirror of the private redirect above. Covers two cases:
// - a paid app that just flipped to free (is_private 1→0)
// whose old `puter.app` URL is still bookmarked/shared;
// - a plain hosted site that has no associated app.
// Redirect to the equivalent `puter.site` URL so the content
// resolves on the correct origin instead of 404ing.
const redirectUrl = buildPublicHostRedirect(req, hostingCfg);
if (redirectUrl) {
res.redirect(302, redirectUrl);
return;
}
// No public hosting domain configured — fall back to refusing
// rather than leaking via the private host.
res.status(404).type('text/plain').send('Subdomain not found');
return;
} else {
@@ -373,13 +391,15 @@ export const createPuterSiteMiddleware = (
associatedApp?.uid
) {
const token =
layers.services.auth.createPublicHostedActorToken({
appUid: associatedApp.uid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain,
host,
});
await layers.services.auth.createPublicHostedActorToken(
{
appUid: associatedApp.uid,
userUid: identity.userUid,
sessionUuid: identity.sessionUuid,
subdomain,
host,
},
);
res.cookie(
layers.services.auth.getPublicHostedActorCookieName(),
token,
@@ -423,7 +443,16 @@ export const createPuterSiteMiddleware = (
// Resolve URL path → absolute FS path under the site root.
let urlPath = req.path || '/';
if (urlPath.endsWith('/')) urlPath += 'index.html';
const decoded = decodeURIComponent(urlPath);
let decoded: string;
try {
decoded = decodeURIComponent(urlPath);
} catch {
// Malformed `%xx` escape — treat as a missing path, not a 500.
res.status(404)
.type('text/html; charset=UTF-8')
.send('<h1>404</h1><p>Not Found</p>');
return;
}
// pathPosix.normalize strips `..` segments; the join with '/' anchors
// it so traversal can't escape the site root.
const resolvedUrlPath = pathPosix.normalize(
@@ -436,10 +465,63 @@ export const createPuterSiteMiddleware = (
}
const filePath = rootPath + resolvedUrlPath;
// Best-effort site config load. A missing / malformed config
// never blocks the request — `loadSiteConfig` swallows all
// errors and returns null on any failure. The Redis cache
// keyed on `rootDirId` keeps the hot path off S3 for the
// common case where the same subdomain gets repeat visits.
let siteConfig: SiteConfig | null = null;
try {
siteConfig = await loadSiteConfig({
rootPath,
rootDirId: rootEntry.id,
fsEntryStore: layers.stores.fsEntry,
fsService: layers.services.fs,
cache: layers.clients.redis,
});
} catch (e) {
console.warn('[puter-site] loadSiteConfig threw', e);
}
// Hide the config file from public serving — visitors should
// see the same 404 as for any other missing path so the
// deployment shape isn't leaked.
const isConfigRequest = isSiteConfigPath(resolvedUrlPath);
// Subdomain hosting bypasses ACL by design: anything the owner placed
// under the registered root_dir is treated as public. Path traversal
// is blocked above by `pathPosix.normalize` anchoring at `/`.
const entry = await layers.stores.fsEntry.getEntryByPath(filePath);
let entry = isConfigRequest
? null
: await layers.stores.fsEntry.getEntryByPath(filePath);
if (entry?.isDir) {
// Folder request → fall back to <folder>/index.html, the same
// way `/` is rewritten to `/index.html` at the site root above.
entry = await layers.stores.fsEntry.getEntryByPath(
pathPosix.join(filePath, 'index.html'),
);
}
// Custom error page fallback. On a 404 we consult the site config
// for a rule and, if one resolves to an existing file, serve that
// instead. Critical: we do this exactly once — the error page
// itself never re-enters error handling, so a misconfigured
// `errors.404.file` that doesn't exist falls through to the
// default 404 page rather than looping.
let statusOverride: number | undefined;
if (!entry || entry.isDir) {
const errorTarget = resolveErrorTarget(siteConfig, 404, rootPath);
if (errorTarget) {
const candidate = await layers.stores.fsEntry.getEntryByPath(
errorTarget.absPath,
);
if (candidate && !candidate.isDir) {
entry = candidate;
statusOverride = errorTarget.status;
}
}
}
if (!entry || entry.isDir) {
res.status(404)
.type('text/html; charset=UTF-8')
@@ -448,8 +530,13 @@ export const createPuterSiteMiddleware = (
}
// Stream the file. `fsEntry.readContent` honours Range + emits
// ETag/Last-Modified when the S3 layer returns them.
// ETag/Last-Modified when the S3 layer returns them. Range
// requests are suppressed when serving a custom error page so
// the visitor always receives the full document with the
// configured status code (no 206 with a stale `bytes=...`
// header from the original request).
const range =
statusOverride === undefined &&
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
@@ -466,6 +553,25 @@ export const createPuterSiteMiddleware = (
const mime =
contentTypeFromMime(entry.name) || 'application/octet-stream';
res.setHeader('Content-Type', mime);
// Fire-and-forget signal for downstream extensions
if (mime === 'text/html' || mime === 'application/xhtml+xml') {
try {
layers.clients.event.emit(
'site.htmlServed',
{
subdomain,
entry,
host,
requestPath: req.path,
mime,
},
{},
);
} catch (e) {
console.warn('[puter-site] site.htmlServed emit failed', e);
}
}
if (download.contentLength !== null) {
res.setHeader('Content-Length', String(download.contentLength));
}
@@ -476,7 +582,7 @@ export const createPuterSiteMiddleware = (
res.setHeader('Last-Modified', download.lastModified.toUTCString());
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Access-Control-Allow-Origin', '*');
res.status(range ? 206 : 200);
res.status(statusOverride ?? (range ? 206 : 200));
// Best-effort egress metering against the site owner. The request
// itself is unauthenticated (public site visitor), so we can't use
@@ -0,0 +1,386 @@
/**
* 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 { 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.
*/
export interface SiteConfigCache {
get(key: string): Promise<string | null>;
set(
key: string,
value: string,
mode: 'EX',
ttlSeconds: number,
): 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;
/** HTTP status to return when serving this error page (200–599). */
status: number;
}
export interface SiteConfig {
/** Map of HTTP status code → custom error rule. Keys are 4xx/5xx. */
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.
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).
*/
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.
*/
export function isSiteConfigPath(urlPath: string): boolean {
const base = pathPosix.basename(urlPath);
return SITE_CONFIG_FILENAMES.some((f) => f.name === base);
}
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.
*/
rootDirId: number;
fsEntryStore: {
getEntryByPath: (path: string) => Promise<FSEntry | null>;
};
fsService: {
readContent: (
entry: FSEntry,
options?: { range?: string },
) => Promise<{
body: NodeJS.ReadableStream;
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.
*/
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.
*/
export async function loadSiteConfig(
args: LoadSiteConfigArgs,
): Promise<SiteConfig | null> {
const { rootPath, rootDirId, fsEntryStore, fsService, cache } = args;
if (!rootPath || rootPath === '/') return null;
// Reject non-positive-integer ids defensively — they'd produce a
// weird cache key and we'd cache something nonsensical against it.
// The store contract is positive integers, but this is a hot path
// for untrusted-origin traffic so we belt-and-brace it.
const cacheable =
Number.isInteger(rootDirId) && rootDirId > 0 && cache !== undefined;
const cacheKey = cacheable ? `${CACHE_KEY_PREFIX}${rootDirId}` : null;
if (cacheable && cacheKey) {
try {
const raw = await cache!.get(cacheKey);
if (raw === NEGATIVE_CACHE_MARKER) return null;
if (typeof raw === 'string' && raw.length > 0) {
// Trust the cached shape — it was produced by this
// same parser, validated, and the TTL is short. Still
// wrap in try/catch in case a different deploy wrote a
// legacy/unparseable value to the same key.
try {
const parsed = JSON.parse(raw) as SiteConfig;
if (parsed && parsed.errors) return parsed;
} catch {
/* fall through to a fresh load */
}
}
} catch {
// Cache transport failure — fall through to live load.
// Don't poison the next request with a half-applied state.
}
}
for (const { name, parse } of SITE_CONFIG_FILENAMES) {
const filePath = pathPosix.join(rootPath, name);
let entry: FSEntry | null;
try {
entry = await fsEntryStore.getEntryByPath(filePath);
} catch (e) {
console.warn('[puter-site] config lookup failed', {
path: filePath,
error: (e as Error)?.message,
});
continue;
}
if (!entry || entry.isDir) continue;
// Reject oversized configs before paying the S3 read. `size` can
// be null for legacy entries — accept and rely on the streaming
// byte counter below.
if (entry.size !== null && entry.size > MAX_CONFIG_BYTES) {
console.warn('[puter-site] config too large, ignoring', {
path: filePath,
size: entry.size,
});
continue;
}
let text: string | null;
try {
text = await readBoundedText(entry, fsService, MAX_CONFIG_BYTES);
} catch (e) {
console.warn('[puter-site] config read failed', {
path: filePath,
error: (e as Error)?.message,
});
continue;
}
// Null means the stream exceeded the byte cap mid-read.
if (text === null) continue;
let parsed: SiteConfig | null;
try {
parsed = parse(text);
} catch (e) {
console.warn('[puter-site] config parse threw', {
path: filePath,
error: (e as Error)?.message,
});
parsed = null;
}
if (parsed && Object.keys(parsed.errors).length > 0) {
if (cacheable && cacheKey) {
writeCache(
cache!,
cacheKey,
JSON.stringify(parsed),
CACHE_TTL_SECONDS,
);
}
return parsed;
}
}
// No file matched (or all matched files parsed to empty). Cache
// the negative result so config-less sites — which are the common
// case — don't keep paying the FS lookup on every visit.
if (cacheable && cacheKey) {
writeCache(cache!, cacheKey, NEGATIVE_CACHE_MARKER, CACHE_TTL_SECONDS);
}
return null;
}
// Fire-and-forget cache write. We never await it on the request path
// because failure is non-fatal and we don't want a slow Redis to add
// latency to the response — the next request just re-loads from FS.
function writeCache(
cache: SiteConfigCache,
key: string,
value: string,
ttlSeconds: number,
): void {
cache.set(key, value, 'EX', ttlSeconds).catch(() => {
/* swallow — cache writes are best-effort */
});
}
/**
* Resolve a custom error rule for `statusCode` into an absolute FS path
* under `rootPath`. Returns null if no rule applies or the rule's `file`
* would escape the site root after normalization. Caller is responsible
* for loop prevention (don't recurse into error handling when serving
* the error page itself).
*/
export function resolveErrorTarget(
config: SiteConfig | null,
statusCode: number,
rootPath: string,
): { absPath: string; status: number } | null {
if (!config) return null;
const rule = config.errors[statusCode];
if (!rule) return null;
// Defence-in-depth: re-normalize at use time. `parsePuterSiteConfig`
// already does this, but keeping the contract here means future
// parsers (Vercel, Netlify) only need to return raw paths and can
// rely on this final guard.
const normalized = pathPosix.normalize(pathPosix.join('/', rule.file));
if (!normalized.startsWith('/') || normalized === '/') return null;
const absPath = rootPath.replace(/\/+$/, '') + normalized;
return { absPath, status: rule.status };
}
// ── Parsers ─────────────────────────────────────────────────────────
function parsePuterSiteConfig(text: string): SiteConfig | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
if (!raw || typeof raw !== 'object') return null;
const errorsField = (raw as { errors?: unknown }).errors;
const errors: Record<number, SiteErrorRule> = {};
if (errorsField && typeof errorsField === 'object') {
for (const [k, v] of Object.entries(
errorsField as Record<string, unknown>,
)) {
const code = Number(k);
// Only 4xx/5xx are meaningful as "error pages" — accepting
// 2xx/3xx keys would let a config silently override the
// happy path, which is out of scope and a footgun.
if (!Number.isInteger(code) || code < 400 || code > 599) continue;
if (!v || typeof v !== 'object') continue;
const rule = v as { file?: unknown; status?: unknown };
if (typeof rule.file !== 'string' || !rule.file.startsWith('/')) {
continue;
}
// Default the response status to the matched error code so a
// bare `{ file: '/404.html' }` Just Works. Allow overriding
// for the SPA-fallback case where 404 should turn into 200.
let status: number;
if (rule.status === undefined) {
status = code;
} else if (
typeof rule.status === 'number' &&
Number.isInteger(rule.status) &&
rule.status >= 200 &&
rule.status <= 599
) {
status = rule.status;
} else {
continue;
}
const normalized = pathPosix.normalize(
pathPosix.join('/', rule.file),
);
// Empty/root after normalize is meaningless as an error page
// (it would resolve to the site root itself).
if (normalized === '/') continue;
errors[code] = { file: normalized, status };
}
}
return { errors };
}
// Returns null when the stream exceeds `maxBytes` (caller treats as
// "config too large, ignore"). The stream is always destroyed before
// return so the S3 connection doesn't leak on early break.
async function readBoundedText(
entry: FSEntry,
fsService: LoadSiteConfigArgs['fsService'],
maxBytes: number,
): Promise<string | null> {
const download = await fsService.readContent(entry);
const stream = download.body as NodeJS.ReadableStream & {
destroy?: () => void;
};
const chunks: Buffer[] = [];
let total = 0;
let exceeded = false;
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
total += chunk.length;
if (total > maxBytes) {
exceeded = true;
break;
}
chunks.push(chunk);
}
} finally {
stream.destroy?.();
}
if (exceeded) return null;
return Buffer.concat(chunks).toString('utf8');
}
+4 -1
View File
@@ -80,12 +80,15 @@ export interface RouteOptions {
*/
subdomain?: string | string[];
/** Reject anonymous + suspended-user requests with 401/403. */
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
requireAuth?: boolean;
/** Reject app/access-token actors. Implies `requireAuth`. */
requireUserActor?: boolean;
/** Allows access-tokens */
allowAccessToken?: 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
@@ -41,6 +41,7 @@ import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
import type {
IChatCompleteResult,
@@ -59,6 +60,136 @@ import { EventMap } from '../../clients/event/types.js';
const MAX_FALLBACKS = 4; // includes first attempt
type ProviderAttempt = {
model: string;
provider: string;
status?: number;
code?: string;
error: string;
};
/**
* Capture what an upstream provider gave us so the classifier downstream
* can decide a user-facing status code instead of always returning 500.
*
* OpenAI-SDK-based providers throw `APIError` with `.status` and a
* structured `.error` body — pull both. For arbitrary errors we fall
* back to the message and a status sniff so providers that throw plain
* `Error("... 503 ...")` strings still classify correctly.
*/
const toAttempt = (
modelId: string,
providerId: string,
err: unknown,
): ProviderAttempt => {
const e = err as {
status?: number;
statusCode?: number;
code?: string;
error?: { code?: string; type?: string; message?: string };
message?: string;
};
const message = e?.message ?? (typeof err === 'string' ? err : String(err));
let status = e?.status ?? e?.statusCode;
if (status === undefined) {
const m = message.match(/\b(4\d\d|5\d\d)\b/);
if (m) status = Number(m[1]);
}
return {
model: modelId,
provider: providerId,
status,
code: e?.error?.code ?? e?.code,
error: message,
};
};
const isRateLimit = (a: ProviderAttempt) =>
a.status === 429 ||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
const isAuthFailure = (a: ProviderAttempt) =>
a.status === 401 ||
a.status === 403 ||
/unauthorized|forbidden|invalid api key/i.test(a.error);
const isUpstream5xx = (a: ProviderAttempt) =>
(a.status !== undefined && a.status >= 500) ||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
a.error,
);
/**
* Map an exhausted fallback chain to a single user-facing HttpError.
*
* Per-class rules (see also alarm gate in server.ts):
* - all rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
* - all auth failures → 500 `upstream_auth_failed` (paged: our config)
* - all upstream 5xx → 400 `upstream_provider_unavailable` (no page)
* - all upstream 4xx (other) → 400 `upstream_bad_request` (no page)
* - mixed → 400 `upstream_failed` (no page)
*/
const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
const fields = { attempts };
if (attempts.length === 0) {
return new HttpError(500, 'No providers attempted', {
legacyCode: 'internal_error',
fields,
});
}
if (attempts.every(isRateLimit)) {
return new HttpError(429, 'AI provider rate limit exceeded', {
legacyCode: 'upstream_rate_limited',
fields,
});
}
if (attempts.every(isAuthFailure)) {
return new HttpError(500, 'AI provider authentication failed', {
legacyCode: 'upstream_auth_failed',
fields,
});
}
if (attempts.every(isUpstream5xx)) {
return new HttpError(400, 'AI provider unavailable', {
legacyCode: 'upstream_provider_unavailable',
fields,
});
}
if (
attempts.every(
(a) => a.status !== undefined && a.status >= 400 && a.status < 500,
)
) {
return new HttpError(400, attempts[0].error, {
legacyCode: 'upstream_bad_request',
fields,
});
}
// Mixed failures where at least one attempt is clearly upstream
// (had an HTTP status from the SDK) means "AI providers couldn't
// satisfy the request" — expose, don't page.
const isUpstreamSignal = (a: ProviderAttempt) =>
a.status !== undefined ||
isRateLimit(a) ||
isAuthFailure(a) ||
isUpstream5xx(a);
if (attempts.some(isUpstreamSignal)) {
return new HttpError(400, 'All AI providers failed', {
legacyCode: 'upstream_failed',
fields,
});
}
// Nothing identifiable as an upstream issue — treat as our bug
// and let the global alarm fire so we actually find out.
return new HttpError(500, 'All providers failed', {
legacyCode: 'internal_error',
fields,
});
};
/**
* Driver implementing the `puter-chat-completion` interface.
*
@@ -289,8 +420,7 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const attempts: { model: string; provider: string; error: string }[] =
[];
const attempts: ProviderAttempt[] = [];
let res: IChatCompleteResult | undefined;
try {
@@ -300,17 +430,12 @@ export class ChatCompletionDriver extends PuterDriver {
provider: model.provider,
});
} catch (e) {
const error = e as Error;
attempts.push({
model: model.id,
provider: model.provider!,
error: error?.message ?? String(e),
});
attempts.push(toAttempt(model.id, model.provider!, e));
// Fallback loop
const tried = [model.id];
const triedProviders = [model.provider!];
let lastError: Error | null = error;
let lastError: Error | null = e as Error;
while (lastError && tried.length < MAX_FALLBACKS) {
const fallback = this.#findFallback(
@@ -347,20 +472,15 @@ export class ChatCompletionDriver extends PuterDriver {
lastError = null;
} catch (fbErr) {
lastError = fbErr as Error;
attempts.push({
model: fallback.id,
provider: fallback.provider!,
error: lastError?.message ?? String(fbErr),
});
attempts.push(
toAttempt(fallback.id, fallback.provider!, fbErr),
);
}
}
}
if (!res) {
throw new HttpError(500, 'All providers failed', {
legacyCode: 'internal_error',
fields: { attempts },
});
throw classifyAttempts(attempts);
}
const username = actor.user?.username;
@@ -531,8 +651,18 @@ export class ChatCompletionDriver extends PuterDriver {
// thinking_tokens → output rate fallback
let rate = costs[key];
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
if (key === 'thinking_tokens' && outputRate !== undefined) {
if (isOutputKey(key) && outputRate !== undefined) {
rate = outputRate;
} else if (!isOutputKey(key)) {
const inputRateRaw = costs[inputKey];
if (
typeof inputRateRaw === 'number' &&
Number.isFinite(inputRateRaw)
) {
rate = inputRateRaw;
} else {
continue;
}
} else {
continue;
}
@@ -751,13 +881,13 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const openrouter = providers['openrouter'];
const openrouterKey = readKey(openrouter);
if (openrouterKey) {
this.#providers['openrouter'] = new OpenRouterProvider(
const alibaba = providers['alibaba'];
const alibabaKey = readKey(alibaba);
if (alibabaKey) {
this.#providers['alibaba'] = new AlibabaProvider(
{
apiKey: openrouterKey,
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
apiKey: alibabaKey,
apiBaseUrl: alibaba?.apiBaseUrl as string | undefined,
},
metering,
);
@@ -782,6 +912,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const openrouter = providers['openrouter'];
const openrouterKey = readKey(openrouter);
if (openrouterKey) {
this.#providers['openrouter'] = new OpenRouterProvider(
{
apiKey: openrouterKey,
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
},
metering,
);
}
// Fake provider — always available for testing
this.#providers['fake-chat'] = new FakeChatProvider();
}
@@ -0,0 +1,63 @@
/**
* 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/>.
*/
/**
* Integration test for the Alibaba provider.
*
* Uses `qwen-turbo` (cheapest generally-available model). Skipped
* when `PUTER_TEST_AI_ALIBABA_API_KEY` is unset.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AlibabaProvider } from './AlibabaProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'AlibabaProvider (integration)',
() => {
it('returns a non-empty completion from qwen-turbo', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new AlibabaProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-turbo',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
max_tokens: 16,
}),
);
const text = (result as { message?: { content?: string } }).message
?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
});
},
);
@@ -0,0 +1,720 @@
/*
* 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/>.
*/
/**
* Offline unit tests for AlibabaProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs AlibabaProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. Alibaba is OpenAI-compatible so the OpenAI SDK is mocked at
* the module boundary; that's the real network egress point. The
* companion integration test (AlibabaProvider.integration.test.ts)
* exercises the real DashScope endpoint.
*/
import { Writable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { ALIBABA_MODELS } from './models.js';
import { AlibabaProvider } from './AlibabaProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { createMock, openAICtor } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
});
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.chat = { completions: { create: createMock } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = (config?: { apiKey?: string; apiBaseUrl?: string }) => {
const provider = new AlibabaProvider(
{ apiKey: 'test-key', ...config },
server.services.metering,
);
return { provider };
};
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
async *[Symbol.asyncIterator]() {
for (const item of items) {
yield item;
}
},
});
const makeCapturingChatStream = () => {
const chunks: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString('utf8'));
cb();
},
});
const chatStream = new AIChatStream({ stream: sink });
return {
chatStream,
events: () =>
chunks
.join('')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
};
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('AlibabaProvider construction', () => {
it('points the OpenAI SDK at the DashScope base URL with the configured key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL:
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
});
});
it('uses a custom base URL when configured', () => {
makeProvider({ apiBaseUrl: 'https://custom.endpoint/v1' });
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://custom.endpoint/v1',
});
});
});
// ── Model catalog ───────────────────────────────────────────────────
describe('AlibabaProvider model catalog', () => {
it('returns qwen-plus-latest as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
});
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(ALIBABA_MODELS);
});
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
for (const m of ALIBABA_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
expect(ids).toContain(a);
}
}
expect(ids).toContain('qwen-plus');
expect(ids).toContain('qwen/qwen-plus');
expect(ids).toContain('qwen-max');
expect(ids).toContain('qwen/qwen-max');
});
});
// ── Request shape ───────────────────────────────────────────────────
describe('AlibabaProvider.complete request shape', () => {
const baseCompletion = {
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('forwards model + messages and defaults max_tokens to 1000 when caller omits it', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('qwen-plus');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
it('respects an explicit max_tokens override', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
}),
);
expect(createMock.mock.calls[0]![0].max_tokens).toBe(256);
});
it('forwards temperature when supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.7,
}),
);
expect(createMock.mock.calls[0]![0].temperature).toBe(0.7);
});
it('omits the `tools` key entirely when no tools are supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect('tools' in args).toBe(false);
});
it('passes tool definitions through unchanged when supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const tools = [
{
type: 'function',
function: {
name: 'lookup',
parameters: { type: 'object', properties: {} },
},
},
];
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
);
expect(createMock.mock.calls[0]![0].tools).toBe(tools);
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
);
const [nonStreamArgs] = createMock.mock.calls[0]!;
expect(nonStreamArgs.stream).toBe(false);
expect('stream_options' in nonStreamArgs).toBe(false);
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
);
const [streamArgs] = createMock.mock.calls[1]!;
expect(streamArgs.stream).toBe(true);
expect(streamArgs.stream_options).toEqual({ include_usage: true });
});
it('hoists Puter-style tool_use blocks into OpenAI tool_calls before sending', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call_1',
name: 'lookup',
input: { q: 'puter' },
},
],
},
],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.messages[0].content).toBeNull();
expect(args.messages[0].tool_calls).toEqual([
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: JSON.stringify({ q: 'puter' }),
},
},
]);
});
});
// ── Model resolution ────────────────────────────────────────────────
describe('AlibabaProvider model resolution', () => {
const baseCompletion = {
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('resolves an exact canonical id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-max',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('qwen-max');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'alibaba:qwen-max',
expect.any(Object),
);
});
it('resolves an alias to its canonical id (alias rewriting)', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen/qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('qwen-plus');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'alibaba:qwen-plus',
expect.any(Object),
);
});
});
// ── Non-stream completion ───────────────────────────────────────────
describe('AlibabaProvider.complete non-stream output', () => {
it('returns the first choice and runs the metered usage calculator', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 100, completion_tokens: 50 },
});
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(result).toMatchObject({
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
});
expect((result as { usage: unknown }).usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 0,
});
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 0,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('alibaba:qwen-plus');
expect(overrides).toEqual({
prompt_tokens: 100 * Number(qwenPlus.costs.prompt_tokens),
completion_tokens: 50 * Number(qwenPlus.costs.completion_tokens),
cached_tokens: 0,
});
});
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: '{"q":"puter"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
}),
)) as { message: { tool_calls?: unknown[] }; finish_reason: string };
expect(result.finish_reason).toBe('tool_calls');
expect(result.message.tool_calls).toEqual([
{
id: 'call_1',
type: 'function',
function: { name: 'lookup', arguments: '{"q":"puter"}' },
},
]);
});
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 7, completion_tokens: 3 },
});
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.cached_tokens).toBe(0);
expect(overrides).toMatchObject({ cached_tokens: 0 });
});
it('accounts for cached_tokens when prompt_tokens_details is present', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 50,
completion_tokens: 20,
prompt_tokens_details: { cached_tokens: 15 },
},
});
await withTestActor(() =>
provider.complete({
model: 'qwen3.6-max-preview',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const model = ALIBABA_MODELS.find(
(m) => m.id === 'qwen3.6-max-preview',
)!;
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 50,
completion_tokens: 20,
cached_tokens: 15,
});
expect(prefix).toBe('alibaba:qwen3.6-max-preview');
expect(overrides).toEqual({
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
completion_tokens: 20 * Number(model.costs.completion_tokens),
cached_tokens: 15 * Number(model.costs.cached_tokens ?? 0),
});
});
});
// ── Streaming deltas ────────────────────────────────────────────────
describe('AlibabaProvider.complete streaming', () => {
it('streams text deltas through to text events and meters final usage', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 4, completion_tokens: 2 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
);
expect((result as { stream: boolean }).stream).toBe(true);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const textEvents = events.filter((e) => e.type === 'text');
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
const usageEvent = events.find((e) => e.type === 'usage');
expect(usageEvent?.usage).toEqual({
prompt_tokens: 4,
completion_tokens: 2,
cached_tokens: 0,
});
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('alibaba:qwen-plus');
expect(overrides).toEqual({
prompt_tokens: 4 * Number(qwenPlus.costs.prompt_tokens),
completion_tokens: 2 * Number(qwenPlus.costs.completion_tokens),
cached_tokens: 0,
});
});
it('builds a tool_use block from streamed function-call deltas', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'call_1',
function: {
name: 'lookup',
arguments: '{"q":',
},
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: { arguments: '"puter"}' },
},
],
},
},
],
},
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const toolEvent = events.find((e) => e.type === 'tool_use');
expect(toolEvent).toBeDefined();
expect(toolEvent?.id).toBe('call_1');
expect(toolEvent?.name).toBe('lookup');
expect(toolEvent?.input).toEqual({ q: 'puter' });
});
});
// ── Error mapping ───────────────────────────────────────────────────
describe('AlibabaProvider.complete error mapping', () => {
it('rethrows errors raised by the OpenAI client unchanged', async () => {
const { provider } = makeProvider();
const apiError = new Error('DashScope exploded');
createMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'boom' }],
}),
),
).rejects.toBe(apiError);
expect(recordSpy).not.toHaveBeenCalled();
});
});
// ── Moderation ──────────────────────────────────────────────────────
describe('AlibabaProvider.checkModeration', () => {
it('throws — Alibaba provider does not implement moderation', () => {
const { provider } = makeProvider();
expect(() => provider.checkModeration('anything')).toThrow(
/not implemented/i,
);
});
});
@@ -0,0 +1,123 @@
/*
* 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 { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { ALIBABA_MODELS } from './models.js';
type AlibabaConfig = {
apiKey: string;
apiBaseUrl?: string;
};
export class AlibabaProvider implements IChatProvider {
#openai: OpenAI;
#meteringService: MeteringService;
constructor(config: AlibabaConfig, meteringService: MeteringService) {
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL:
config.apiBaseUrl ??
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
});
this.#meteringService = meteringService;
}
getDefaultModel() {
return 'qwen-plus-latest';
}
models() {
return ALIBABA_MODELS;
}
async list() {
const models = this.models();
const modelNames: string[] = [];
for (const model of models) {
modelNames.push(model.id);
if (model.aliases) {
modelNames.push(...model.aliases);
}
}
return modelNames;
}
async complete({
messages,
stream,
model,
tools,
max_tokens,
temperature,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
messages = await OpenAIUtil.process_input_messages(messages);
const completion = await this.#openai.chat.completions.create({
messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
max_tokens: max_tokens || 1000,
temperature,
stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
} as ChatCompletionCreateParams);
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
const costsOverride = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
`alibaba:${modelUsed.id}`,
costsOverride,
);
return trackedUsage;
},
stream,
completion,
});
}
checkModeration(_text: string) {
throw new Error('Method not implemented.');
}
}
File diff suppressed because it is too large. Load diff
@@ -145,9 +145,9 @@ describe('DeepSeekProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('DeepSeekProvider model catalog', () => {
it('returns deepseek-chat as the default', () => {
it('returns deepseek-v4-flash as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('deepseek-chat');
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
});
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
@@ -164,8 +164,10 @@ describe('DeepSeekProvider model catalog', () => {
expect(ids).toContain(a);
}
}
expect(ids).toContain('deepseek-v4-flash');
expect(ids).toContain('deepseek-v4-pro');
expect(ids).toContain('deepseek-chat');
expect(ids).toContain('deepseek/deepseek-chat');
expect(ids).toContain('deepseek/deepseek-v4-pro');
});
});
@@ -188,13 +190,13 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('deepseek-chat');
expect(args.model).toBe('deepseek-v4-flash');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
@@ -205,7 +207,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
}),
@@ -220,7 +222,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -244,7 +246,7 @@ describe('DeepSeekProvider.complete request shape', () => {
];
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
@@ -258,7 +260,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
@@ -270,7 +272,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -286,7 +288,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{
role: 'assistant',
@@ -327,7 +329,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{
role: 'assistant',
@@ -364,7 +366,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{ role: 'user', content: 'do tool call' },
{
@@ -412,16 +414,16 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-reasoner',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-reasoner');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-reasoner',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -432,17 +434,17 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// `deepseek/deepseek-chat` is an alias of `deepseek-chat`.
model: 'deepseek/deepseek-chat',
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
model: 'deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-chat',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -458,11 +460,41 @@ describe('DeepSeekProvider model resolution', () => {
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-chat',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
// so callers using the old names get transparently upgraded — and
// metered against the v4-flash canonical prefix.
it.each([
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek:deepseek/deepseek-chat',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: alias,
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -489,7 +521,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -504,8 +536,8 @@ describe('DeepSeekProvider.complete non-stream output', () => {
cached_tokens: 10,
});
// deepseek-chat costs: prompt=56, completion=168, cached=0.
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
@@ -514,7 +546,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
cached_tokens: 10,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('deepseek:deepseek-chat');
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(overrides).toEqual({
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
completion_tokens: 50 * Number(chat.costs.completion_tokens),
@@ -549,7 +581,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
@@ -584,7 +616,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -617,7 +649,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -642,10 +674,10 @@ describe('DeepSeekProvider.complete streaming', () => {
cached_tokens: 1,
});
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('deepseek:deepseek-chat');
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(overrides).toEqual({
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
completion_tokens: 2 * Number(chat.costs.completion_tokens),
@@ -698,7 +730,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
@@ -737,7 +769,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -40,7 +40,7 @@ export class DeepSeekProvider implements IChatProvider {
}
getDefaultModel() {
return 'deepseek-chat';
return 'deepseek-v4-flash';
}
models() {
@@ -137,20 +137,18 @@ export class DeepSeekProvider implements IChatProvider {
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
actor!,
`deepseek:${modelUsed.id}`,
costsOverrideFromModel,
);
return trackedUsage;
},
stream,
stream: stream,
completion,
});
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
checkModeration(_text: string) {
throw new Error('Method not implemented.');
}
}
@@ -22,47 +22,56 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const DEEPSEEK_MODELS: IChatModel[] = [
{
puterId: 'deepseek:deepseek/deepseek-chat',
id: 'deepseek-chat',
puterId: 'deepseek:deepseek/deepseek-v4-flash',
id: 'deepseek-v4-flash',
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-12-26',
knowledge: '2026-04',
release_date: '2026-04-24',
name: 'DeepSeek Chat',
aliases: ['deepseek/deepseek-chat'],
context: 128000,
aliases: [
'deepseek-v4-flash',
'deepseek/deepseek-v4-flash',
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek/deepseek-v4-flash',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-chat',
],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 56,
completion_tokens: 168,
cached_tokens: 0,
prompt_tokens: 14,
completion_tokens: 28,
cached_tokens: 0.28,
},
max_tokens: 8000,
max_tokens: 384_000,
},
{
puterId: 'deepseek:deepseek/deepseek-reasoner',
id: 'deepseek-reasoner',
puterId: 'deepseek:deepseek/deepseek-v4-pro',
id: 'deepseek-v4-pro',
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-07',
release_date: '2025-01-20',
name: 'DeepSeek Reasoner',
aliases: ['deepseek/deepseek-reasoner'],
context: 128000,
knowledge: '2026-04',
release_date: '2026-04-24',
name: 'DeepSeek Chat',
aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 56,
completion_tokens: 168,
cached_tokens: 0,
prompt_tokens: 174,
completion_tokens: 348,
cached_tokens: 1.45,
},
max_tokens: 64000,
max_tokens: 384_000,
},
];
@@ -21,6 +21,31 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const GEMINI_MODELS: IChatModel[] = [
{
puterId: 'google:google/gemini-3.5-flash',
id: 'gemini-3.5-flash',
modalities: {
input: ['text', 'image', 'audio', 'video', 'pdf'],
output: ['text'],
},
open_weights: false,
tool_call: true,
knowledge: '2025-01',
release_date: '2026-05-19',
name: 'Gemini 3.5 Flash',
aliases: ['google/gemini-3.5-flash'],
context: 1_048_576,
max_tokens: 65_536,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 150,
completion_tokens: 900,
cached_tokens: 15,
},
},
{
puterId: 'google:google/gemini-2.0-flash',
id: 'gemini-2.0-flash',
@@ -20,7 +20,7 @@
/**
* Integration test for the Mistral provider.
*
* Uses `mistral-small-2506` (provider default, cheapest tier). Skipped
* Uses `mistral-small-2603` (provider default, cheapest tier). Skipped
* when `PUTER_TEST_AI_MISTRAL_API_KEY` is unset.
*/
@@ -47,7 +47,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
@@ -149,9 +149,9 @@ describe('MistralAIProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('MistralAIProvider model catalog', () => {
it('returns mistral-small-2506 as the default', () => {
it('returns the configured small model as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('mistral-small-2506');
expect(provider.getDefaultModel()).toBe('mistral-small-2603');
});
it('exposes the static MISTRAL_MODELS list verbatim from models()', async () => {
@@ -171,7 +171,7 @@ describe('MistralAIProvider model catalog', () => {
}
// Sanity: a known alias is present alongside its canonical id.
expect(ids).toContain('mistral-small-latest');
expect(ids).toContain('mistral-small-2506');
expect(ids).toContain('mistral-small-2603');
});
});
@@ -194,7 +194,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 256,
temperature: 0.4,
@@ -202,7 +202,7 @@ describe('MistralAIProvider.complete request shape', () => {
);
const [args] = completeMock.mock.calls[0]!;
expect(args.model).toBe('mistral-small-2506');
expect(args.model).toBe('mistral-small-2603');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
// Mistral's SDK uses maxTokens (camelCase). The provider should
// adapt our snake_case input.
@@ -216,7 +216,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -240,7 +240,7 @@ describe('MistralAIProvider.complete request shape', () => {
];
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
@@ -257,7 +257,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [
{
role: 'assistant',
@@ -308,7 +308,7 @@ describe('MistralAIProvider.complete request shape', () => {
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
@@ -320,7 +320,7 @@ describe('MistralAIProvider.complete request shape', () => {
streamMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -370,17 +370,17 @@ describe('MistralAIProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// `mistral-small-latest` is an alias of `mistral-small-2506`.
// `mistral-small-latest` is an alias of `mistral-small-2603`.
model: 'mistral-small-latest',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'mistral:mistral-small-2506',
'mistral:mistral-small-2603',
expect.any(Object),
);
});
@@ -396,11 +396,11 @@ describe('MistralAIProvider model resolution', () => {
}),
);
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'mistral:mistral-small-2506',
'mistral:mistral-small-2603',
expect.any(Object),
);
});
@@ -424,7 +424,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -450,12 +450,12 @@ describe('MistralAIProvider.complete non-stream output', () => {
cached_tokens: 0,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('mistral:mistral-small-2506');
// mistral-small-2506 costs: prompt=10, completion=30. cached_tokens
expect(prefix).toBe('mistral:mistral-small-2603');
// mistral-small-2603 costs: prompt=15, completion=60. cached_tokens
// is undefined in the model row → multiplied by 0 → NaN-safe 0.
expect(overrides).toMatchObject({
prompt_tokens: 100 * 10,
completion_tokens: 50 * 30,
prompt_tokens: 100 * 15,
completion_tokens: 50 * 60,
});
});
@@ -486,7 +486,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
@@ -535,7 +535,7 @@ describe('MistralAIProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -560,14 +560,14 @@ describe('MistralAIProvider.complete streaming', () => {
cached_tokens: 0,
});
// mistral-small-2506: prompt=10, completion=30.
// mistral-small-2603: prompt=15, completion=60.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(prefix).toBe('mistral:mistral-small-2506');
expect(prefix).toBe('mistral:mistral-small-2603');
expect(overrides).toMatchObject({
prompt_tokens: 4 * 10,
completion_tokens: 2 * 30,
prompt_tokens: 4 * 15,
completion_tokens: 2 * 60,
});
});
@@ -626,7 +626,7 @@ describe('MistralAIProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
@@ -666,7 +666,7 @@ describe('MistralAIProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -21,12 +21,12 @@ import { Mistral } from '@mistralai/mistralai';
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import type {
IChatCompleteResult,
IChatProvider,
ICompleteArguments,
IChatCompleteResult,
} from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { MISTRAL_MODELS } from './models.js';
export class MistralAIProvider implements IChatProvider {
@@ -42,7 +42,7 @@ export class MistralAIProvider implements IChatProvider {
}
getDefaultModel() {
return 'mistral-small-2506';
return 'mistral-small-2603';
}
async models() {
@@ -19,8 +19,37 @@
import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
// Hardcoded from https://models.dev/api.json and https://docs.mistral.ai/models/overview
export const MISTRAL_MODELS: IChatModel[] = [
{
puterId: 'mistralai:mistralai/mistral-medium-3-5',
id: 'mistral-medium-3-5',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2026-04',
release_date: '2026-04-29',
name: 'Mistral Medium 3.5',
aliases: [
'mistral-medium-3',
'mistral-medium-2604',
'mistralai/mistral-medium-3-5',
'mistralai/mistral-medium-2604',
],
context: 262_144,
max_tokens: 262_144,
description:
'State-of-the-art multimodal model optimized for agentic and coding tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 150,
completion_tokens: 750,
},
},
{
puterId: 'mistralai:mistralai/mistral-medium-2508',
id: 'mistral-medium-2508',
@@ -29,106 +58,82 @@ export const MISTRAL_MODELS: IChatModel[] = [
tool_call: true,
knowledge: '2025-05',
release_date: '2025-08-12',
name: 'mistral-medium-2508',
name: 'Mistral Medium 3.1',
aliases: [
'mistral-medium-latest',
'mistral-medium',
'mistralai/mistral-medium-2508',
'mistral-medium-2508',
'mistral-medium-latest',
'mistralai/mistral-medium-latest',
],
context: 131072,
max_tokens: 131072,
description: 'Update on Mistral Medium 3 with improved capabilities.',
context: 262_144,
max_tokens: 262_144,
description: 'Frontier-class multimodal model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 40,
completion_tokens: 200,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-7b',
id: 'open-mistral-7b',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2023-12',
release_date: '2023-09-27',
name: 'open-mistral-7b',
aliases: [
'mistral-tiny',
'mistral-tiny-2312',
'mistralai/open-mistral-7b',
],
context: 32768,
max_tokens: 32768,
description: 'Our first dense model released September 2023.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 25,
completion_tokens: 25,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-nemo',
id: 'open-mistral-nemo',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-07-01',
name: 'open-mistral-nemo',
aliases: [
'open-mistral-nemo-2407',
'mistral-tiny-2407',
'mistral-tiny-latest',
'mistralai/open-mistral-nemo',
],
context: 131072,
max_tokens: 131072,
description:
'Our best multilingual open source model released July 2024.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/pixtral-large-2411',
id: 'pixtral-large-2411',
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-11',
release_date: '2024-11-01',
name: 'pixtral-large-2411',
knowledge: '2025-12',
release_date: '2025-12-02',
name: 'Mistral Large 3',
aliases: [
'pixtral-large-latest',
'mistral-large-pixtral-2411',
'mistralai/pixtral-large-2411',
'mistral-large-latest',
'mistral-large',
'mistralai/mistral-large-latest',
'mistralai/mistral-large-2512',
],
context: 131072,
max_tokens: 131072,
description: 'Official pixtral-large-2411 Mistral AI model',
context: 262_144,
max_tokens: 262_144,
description:
'State-of-the-art, open-weight, general-purpose multimodal model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 200,
completion_tokens: 600,
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/mistral-small-2603',
id: 'mistral-small-2603',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-06',
release_date: '2026-03-16',
name: 'Mistral Small 4',
aliases: [
'mistral-small-latest',
'mistral-small',
'mistralai/mistral-small-latest',
'mistralai/mistral-small-2603',
],
context: 256_000,
max_tokens: 256_000,
description:
'Hybrid open-weight model for instruct, reasoning, and coding tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
completion_tokens: 60,
},
},
{
@@ -137,116 +142,73 @@ export const MISTRAL_MODELS: IChatModel[] = [
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-05-29',
name: 'codestral-2508',
knowledge: '2025-08',
release_date: '2025-08-29',
name: 'Codestral',
aliases: ['codestral-latest', 'mistralai/codestral-2508'],
context: 256000,
max_tokens: 256000,
context: 256_000,
max_tokens: 256_000,
description:
'Our cutting-edge language model for coding released August 2025.',
'Cutting-edge language model for code completion, proficient in over 80 programming languages.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 30,
completion_tokens: 90,
},
},
{
puterId: 'mistralai:mistralai/devstral-small-2507',
id: 'devstral-small-2507',
puterId: 'mistralai:mistralai/devstral-2512',
id: 'devstral-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-05',
release_date: '2025-07-10',
name: 'devstral-small-2507',
aliases: ['devstral-small-latest', 'mistralai/devstral-small-2507'],
context: 131072,
max_tokens: 131072,
description: 'Our small open-source code-agentic model.',
knowledge: '2025-12',
release_date: '2025-12-09',
name: 'Devstral 2',
aliases: [
'devstral-latest',
'mistralai/devstral-latest',
'mistralai/devstral-2512',
'devstral-medium-latest',
'mistralai/devstral-medium-latest',
],
context: 262_144,
max_tokens: 262_144,
description:
'Frontier open-source code agents model for software engineering.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
cached_tokens: 0,
},
},
{
puterId: 'mistralai:mistralai/devstral-medium-2507',
id: 'devstral-medium-2507',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-05',
release_date: '2025-07-10',
name: 'devstral-medium-2507',
aliases: ['devstral-medium-latest', 'mistralai/devstral-medium-2507'],
context: 131072,
max_tokens: 131072,
description: 'Our medium code-agentic model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 40,
completion_tokens: 200,
cached_tokens: 0,
},
},
{
puterId: 'mistralai:mistralai/mistral-small-2506',
id: 'mistral-small-2506',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-03',
release_date: '2025-06-20',
name: 'mistral-small-2506',
aliases: ['mistral-small-latest', 'mistralai/mistral-small-2506'],
context: 131072,
max_tokens: 131072,
description:
'Our latest enterprise-grade small model with the latest version released June 2025.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
{
puterId: 'mistralai:mistralai/magistral-medium-2509',
id: 'magistral-medium-2509',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
open_weights: false,
tool_call: true,
knowledge: '2025-06',
release_date: '2025-03-17',
name: 'magistral-medium-2509',
release_date: '2025-09-01',
name: 'Magistral Medium 1.2',
aliases: ['magistral-medium-latest', 'mistralai/magistral-medium-2509'],
context: 131072,
max_tokens: 131072,
description:
'Our frontier-class reasoning model release candidate September 2025.',
context: 131_072,
max_tokens: 131_072,
description: 'Frontier-class multimodal reasoning model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 500,
},
@@ -258,143 +220,149 @@ export const MISTRAL_MODELS: IChatModel[] = [
open_weights: true,
tool_call: true,
knowledge: '2025-06',
release_date: '2025-03-17',
name: 'magistral-small-2509',
release_date: '2025-09-01',
name: 'Magistral Small 1.2',
aliases: ['magistral-small-latest', 'mistralai/magistral-small-2509'],
context: 131072,
max_tokens: 131072,
description: 'Our efficient reasoning model released September 2025.',
context: 131_072,
max_tokens: 131_072,
description: 'Efficient open-weight reasoning model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/voxtral-mini-2507',
id: 'voxtral-mini-2507',
name: 'voxtral-mini-2507',
aliases: ['voxtral-mini-latest', 'mistralai/voxtral-mini-2507'],
context: 32768,
max_tokens: 32768,
description: 'A mini audio understanding model released in July 2025',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 4,
completion_tokens: 4,
},
},
{
puterId: 'mistralai:mistralai/voxtral-small-2507',
id: 'voxtral-small-2507',
name: 'voxtral-small-2507',
aliases: ['voxtral-small-latest', 'mistralai/voxtral-small-2507'],
context: 32768,
max_tokens: 32768,
description: 'A small audio understanding model released in July 2025',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
{
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-latest',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-11',
release_date: '2024-11-01',
name: 'mistral-large-2512',
aliases: ['mistral-large-2512', 'mistralai/mistral-large-2512'],
context: 262144,
max_tokens: 262144,
description: 'Official mistral-large-2512 Mistral AI model',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/ministral-3b-2512',
id: 'ministral-3b-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-10-01',
name: 'ministral-3b-2512',
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
context: 131072,
max_tokens: 131072,
description: 'Ministral 3 (a.k.a. Tinystral) 3B Instruct.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 10,
},
},
{
puterId: 'mistralai:mistralai/ministral-8b-2512',
id: 'ministral-8b-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-10-01',
name: 'ministral-8b-2512',
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
context: 262144,
max_tokens: 262144,
description: 'Ministral 3 (a.k.a. Tinystral) 8B Instruct.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/ministral-14b-2512',
id: 'ministral-14b-2512',
name: 'ministral-14b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 14B',
aliases: ['ministral-14b-latest', 'mistralai/ministral-14b-2512'],
context: 262144,
max_tokens: 262144,
description: 'Ministral 3 (a.k.a. Tinystral) 14B Instruct.',
context: 262_144,
max_tokens: 262_144,
description:
'Best-in-class compact model with text and vision capabilities.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 20,
completion_tokens: 20,
},
},
{
puterId: 'mistralai:mistralai/ministral-8b-2512',
id: 'ministral-8b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 8B',
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
context: 262_144,
max_tokens: 262_144,
description:
'Efficient compact model with text and vision capabilities.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 10,
},
},
{
puterId: 'mistralai:mistralai/ministral-3b-2512',
id: 'ministral-3b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 3B',
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
context: 131_072,
max_tokens: 131_072,
description: 'Tiny, efficient model with multimodal support.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 4,
completion_tokens: 4,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-nemo-2407',
id: 'open-mistral-nemo-2407',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-07-25',
name: 'Mistral Nemo 12B',
aliases: [
'open-mistral-nemo',
'mistral-nemo',
'mistral-nemo-latest',
'mistralai/open-mistral-nemo',
'mistralai/open-mistral-nemo-2407',
'mistralai/mistral-nemo',
],
context: 128_000,
max_tokens: 128_000,
description: 'Best multilingual open source model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/voxtral-small-2507',
id: 'voxtral-small-2507',
modalities: { input: ['text', 'audio'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-07',
release_date: '2025-07-15',
name: 'Voxtral Small',
aliases: [
'voxtral-small-latest',
'mistralai/voxtral-small-2507',
'mistralai/voxtral-small-latest',
'voxtral-small-latest',
],
context: 32_768,
max_tokens: 32_768,
description: 'Audio-input model for instruct tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
];
@@ -87,6 +87,69 @@ export const OPEN_AI_MODELS: IChatModel[] = [
context: 1_050_000,
max_tokens: 1_050_000,
},
{
puterId: 'openai:openai/gpt-5.4-pro',
id: 'gpt-5.4-pro',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-05',
aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 3000,
completion_tokens: 18000,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.4-mini',
id: 'gpt-5.4-mini',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 75,
cached_tokens: 7.5,
completion_tokens: 450,
},
context: 400_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.4-nano',
id: 'gpt-5.4-nano',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-19',
aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
cached_tokens: 2,
completion_tokens: 125,
},
context: 400_000,
max_tokens: 128_000,
},
{
puterId: 'openai:openai/gpt-5.3-codex',
id: 'gpt-5.3-codex',
@@ -92,6 +92,7 @@ const SAMPLE_API_MODELS = [
{
id: 'openai/gpt-5-nano',
name: 'GPT-5 Nano',
created: 1714564800,
context_length: 128000,
pricing: { prompt: 0.00001, completion: 0.00003 },
top_provider: { max_completion_tokens: 16000 },
@@ -99,6 +100,7 @@ const SAMPLE_API_MODELS = [
{
id: 'anthropic/claude-haiku-4.5',
name: 'Claude Haiku 4.5',
created: 1760529600,
context_length: 200000,
pricing: { prompt: 0.000002, completion: 0.00001 },
top_provider: { max_completion_tokens: 8192 },
@@ -107,6 +109,7 @@ const SAMPLE_API_MODELS = [
// 'openrouter/auto' is filtered out — disallowed.
id: 'openrouter/auto',
name: 'Auto',
created: 1704067200,
context_length: 32768,
pricing: { prompt: 0, completion: 0 },
top_provider: { max_completion_tokens: 4096 },
@@ -224,6 +227,23 @@ describe('OpenRouterProvider model catalog', () => {
// Second call should be a cache hit, not a second axios request.
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
});
it('maps OpenRouter created timestamps to release_date metadata', async () => {
const { provider } = makeProvider();
const models = await provider.models();
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'openrouter:openai/gpt-5-nano',
release_date: '2024-05-01',
}),
expect.objectContaining({
id: 'openrouter:anthropic/claude-haiku-4.5',
release_date: '2025-10-15',
}),
]),
);
});
});
// ── Disallowed model gate ───────────────────────────────────────────
@@ -36,6 +36,18 @@ type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
cost?: number;
};
const openRouterReleaseDate = (created: unknown): string | undefined => {
const seconds = Number(created);
if (!Number.isFinite(seconds) || seconds <= 0) {
return undefined;
}
const date = new Date(seconds * 1000);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export class OpenRouterProvider implements IChatProvider {
#meteringService: MeteringService;
@@ -230,7 +242,7 @@ export class OpenRouterProvider implements IChatProvider {
});
models = resp.data.data;
kv.set('openrouterChat:models', models);
kv.set('openrouterChat:models', models, { EX: 15 * 60 }); // cache for 15 minutes
} catch (e) {
console.log(e);
}
@@ -275,6 +287,7 @@ export class OpenRouterProvider implements IChatProvider {
tokens: 1_000_000,
...microcentCosts,
},
release_date: openRouterReleaseDate(model.created),
...overridenModel,
});
}
@@ -433,10 +433,10 @@ describe('TogetherAIProvider.complete non-stream output', () => {
completion_tokens: 50,
cached_tokens: 0,
});
// Qwen pricing: input=20, output=20 (per million).
// Qwen pricing: input=20, output=20 (per million, dollars from API → ×100 for cents).
expect(overrides).toMatchObject({
prompt_tokens: 100 * 20,
completion_tokens: 50 * 20,
prompt_tokens: 100 * 20 * 100,
completion_tokens: 50 * 20 * 100,
});
});
});
@@ -484,13 +484,13 @@ describe('TogetherAIProvider.complete streaming', () => {
cached_tokens: 0,
});
// Qwen pricing: input=20, output=20.
// Qwen pricing: input=20, output=20 (dollars from API → ×100 for cents).
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('togetherai:Qwen/Qwen2.5-7B-Instruct-Turbo');
expect(overrides).toMatchObject({
prompt_tokens: 4 * 20,
completion_tokens: 2 * 20,
prompt_tokens: 4 * 20 * 100,
completion_tokens: 2 * 20 * 100,
});
});
});
@@ -75,7 +75,11 @@ export class TogetherAIProvider implements IChatProvider {
output_cost_key: 'output',
costs: {
tokens: 1_000_000,
...model.pricing,
...Object.fromEntries(
Object.entries(model.pricing ?? {}).map(
([k, v]) => [k, (v as number) * 100],
),
),
},
max_tokens: model.context_length ?? 8000,
});
@@ -96,7 +100,7 @@ export class TogetherAIProvider implements IChatProvider {
},
max_tokens: 1000,
});
kv.set(this.#kvKey, models, { EX: 5 * 60 });
kv.set(this.#kvKey, models, { EX: 15 * 60 });
return models;
}
@@ -126,9 +130,13 @@ export class TogetherAIProvider implements IChatProvider {
const actor = Context.get('actor');
const models = await this.models();
const modelLower = model.toLowerCase();
const modelUsed =
models.find((m) => [m.id, ...(m.aliases || [])].includes(model)) ||
models.find((m) => m.id === this.getDefaultModel())!;
models.find((m) =>
[m.id, ...(m.aliases || [])].some(
(id) => id.toLowerCase() === modelLower,
),
) || models.find((m) => m.id === this.getDefaultModel())!;
const modelIdForParams = modelUsed.id.startsWith('togetherai:')
? modelUsed.id.slice('togetherai:'.length)
: modelUsed.id;
+1
View File
@@ -125,4 +125,5 @@ export interface IChatProvider {
list(): string[] | Promise<string[]>;
getDefaultModel(): string;
complete(arg: ICompleteArguments): Promise<IChatCompleteResult>;
checkModeration(text: string): { flagged: boolean; categories: string[] };
}
@@ -384,20 +384,10 @@ export const create_chat_stream_handler_responses_api =
chatStream.end(usage);
};
/**
*
* @param {object} params
* @param {(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown } params.usage_calculator
* @returns
*/
export const handle_completion_output = async ({
deviations,
stream,
completion,
moderate,
usage_calculator,
finally_fn,
}) => {
export const handle_completion_output = async (
/** @type {Record<string,unknown> & {usage_calculator:(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown }}*/
{ deviations, stream, completion, moderate, usage_calculator, finally_fn },
) => {
deviations = Object.assign(
{
// affected by: Mistral
@@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => {
});
});
// ── puter_output_path ─────────────────────────────────────────────
describe('ImageGenerationDriver.generate puter_output_path', () => {
const TEST_ACTOR: import('../../core/actor.js').Actor = {
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
};
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
it('throws 400 when puter_output_path resolves to root', async () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('throws 400 when puter_output_path parent is root (e.g. /image.png)', async () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/image.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('throws 403 when ACL denies write access to the destination', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(false);
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/somedir/image.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('ACL check runs BEFORE provider.generate so credits are not wasted on a denied path', async () => {
const callOrder: string[] = [];
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockImplementation(async () => {
callOrder.push('acl');
return false;
});
openaiImagesGenerateMock.mockImplementation(async () => {
callOrder.push('provider');
return { data: [{ url: 'https://oai/img.png' }] };
});
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(callOrder).toEqual(['acl']);
});
it('resolves ~ in puter_output_path to /<username>/', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '~/images/out.png',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
).toBe('/testuser/images/out.png');
});
it('writes the generated image to FS and still returns the result URL', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const result = await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/photos/out.png',
} as never),
);
expect(result).toBe('https://oai/img.png');
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(userId).toBe(42);
const meta = (
writeArg as {
fileMetadata: {
path: string;
contentType: string;
overwrite: boolean;
};
}
).fileMetadata;
expect(meta.path).toBe('/testuser/photos/out.png');
expect(meta.contentType).toBe('image/png');
expect(meta.overwrite).toBe(true);
});
it('does not forward puter_output_path to the upstream provider call', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
);
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.puter_output_path).toBeUndefined();
});
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
const noIdActor: import('../../core/actor.js').Actor = {
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
};
await expect(
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/noone/dir/img.png',
} as never),
),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
});
// Avoid coupling the 'unused' XAI export to lint. The catalog reference
// is also used implicitly by the routing tests above.
void XAI_IMAGE_GENERATION_MODELS;
@@ -18,8 +18,12 @@
*/
import crypto from 'node:crypto';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
@@ -114,12 +118,34 @@ export class ImageGenerationDriver extends PuterDriver {
}
async generate(args: IGenerateParams): Promise<string> {
const actor = Context.get('actor');
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;
// Validate the output path early — before spending credits.
let resolvedOutputPath: string | undefined;
if (puterOutputPath) {
const username = actor.user?.username;
const userId = actor.user?.id;
if (!userId || !username) {
throw new HttpError(
400,
'User ID required for puter_output_path',
{ legacyCode: 'bad_request' },
);
}
resolvedOutputPath = this.#resolveOutputPath(
puterOutputPath,
username,
);
await this.#assertWriteAccess(actor, resolvedOutputPath);
}
let modelId = args.model?.trim().toLowerCase();
let intendedProvider =
args.provider ?? (Context.get('driverName') as string | undefined);
@@ -172,11 +198,17 @@ export class ImageGenerationDriver extends PuterDriver {
{},
);
return provider.generate({
const result = await provider.generate({
...args,
model: model.id,
provider: model.provider,
});
if (resolvedOutputPath) {
await this.#saveToFS(actor, result, resolvedOutputPath);
}
return result;
}
#normalizeRatio(parameters: IGenerateParams) {
@@ -340,6 +372,106 @@ export class ImageGenerationDriver extends PuterDriver {
}
}
async #saveToFS(
actor: Actor,
result: string,
resolvedPath: string,
): Promise<void> {
const userId = actor.user!.id!;
let buffer: Buffer;
let contentType: string;
if (result.startsWith('data:')) {
const commaIdx = result.indexOf(',');
const header = result.substring(0, commaIdx);
contentType =
header.match(/data:(.*?);/)?.[1] ?? 'application/octet-stream';
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
} else {
const response = await fetch(result);
if (!response.ok) {
throw new HttpError(
502,
`Failed to fetch generated image for FS write: ${response.status}`,
{ legacyCode: 'internal_error' },
);
}
contentType =
response.headers.get('content-type') ??
'application/octet-stream';
buffer = Buffer.from(await response.arrayBuffer());
}
await this.services.fs.write(userId, {
fileMetadata: {
path: resolvedPath,
size: buffer.length,
contentType,
overwrite: true,
createMissingParents: true,
},
fileContent: Readable.from(buffer),
});
}
#resolveOutputPath(outputPath: string, username: string): string {
let resolved = outputPath.trim();
if (resolved === '~' || resolved.startsWith('~/')) {
resolved = `/${username}${resolved.slice(1)}`;
}
assertNormalized(resolved);
if (!resolved.startsWith('/')) {
resolved = `/${resolved}`;
}
if (resolved.length > 1 && resolved.endsWith('/')) {
resolved = resolved.slice(0, -1);
}
return resolved;
}
async #assertWriteAccess(
actor: Actor,
resolvedPath: string,
): Promise<void> {
if (resolvedPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const parentPath = pathPosix.dirname(resolvedPath);
if (parentPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const pathToCheck = parentPath;
const fsService = this.services.fs;
let ancestorsCache: Promise<
Array<{ uid: string; path: string }>
> | null = null;
const canWrite = await this.services.acl.check(
actor,
{
path: pathToCheck,
resolveAncestors() {
if (!ancestorsCache) {
ancestorsCache =
fsService.getAncestorChain(pathToCheck);
}
return ancestorsCache;
},
},
'write',
);
if (!canWrite) {
throw new HttpError(403, 'Write access denied for destination', {
legacyCode: 'access_denied',
});
}
}
#resolveModel(modelId: string, provider?: string): IImageModel | null {
const models = this.#modelIdMap[modelId];
if (!models || models.length === 0) return null;
@@ -351,9 +351,14 @@ describe('TogetherImageProvider.generate output handling', () => {
expect(result).toBe('data:image/png;base64,AAAA');
});
it('wraps SDK errors with an "image generation error:" prefix', async () => {
it('lets SDK errors bubble untouched so the driver boundary can classify them', async () => {
const provider = makeProvider();
const apiError = new Error('upstream blew up');
// Together's SDK errors carry a `.status` field — re-wrapping
// them in a plain Error stripped that out and caused the
// catch-all `translateProviderError` to fall through to 500.
const apiError = Object.assign(new Error('upstream blew up'), {
status: 400,
});
generateMock.mockRejectedValueOnce(apiError);
await expect(
@@ -363,7 +368,7 @@ describe('TogetherImageProvider.generate output handling', () => {
prompt: 'hi',
}),
),
).rejects.toThrow(/Together AI image generation error:.*upstream blew up/);
).rejects.toMatchObject({ status: 400, message: 'upstream blew up' });
// Failure path must NOT meter usage.
expect(incrementUsageSpy).not.toHaveBeenCalled();
@@ -172,43 +172,51 @@ export class TogetherImageProvider implements IImageProvider {
model: selectedModel.id.replace('togetherai:', ''),
}) as unknown as Together.Images.ImageGenerateParams;
try {
const response = await this.#client.images.generate(request);
if (!response?.data?.length) {
throw new Error(
'Together AI response did not include image data',
);
}
this.#meteringService.incrementUsage(
actor,
usageType,
usageAmount,
costInMicroCents,
);
const first = response.data[0] as {
url?: string;
b64_json?: string;
};
const url =
first.url ||
(first.b64_json
? `data:image/png;base64,${first.b64_json}`
: undefined);
if (!url) {
throw new Error(
'Together AI response did not include an image URL',
);
}
return url;
} catch (error) {
throw new Error(
`Together AI image generation error: ${(error as Error).message}`,
// Let SDK errors bubble — together-ai SDK errors carry `.status`
// which the driver-boundary `translateProviderError` maps to
// `upstream_*` HttpErrors. Re-wrapping in `new Error(...)` would
// strip the status field and cause these to surface as 500s.
const response = await this.#client.images.generate(request);
if (!response?.data?.length) {
throw new HttpError(
400,
'Together AI response did not include image data',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'together' },
},
);
}
this.#meteringService.incrementUsage(
actor,
usageType,
usageAmount,
costInMicroCents,
);
const first = response.data[0] as {
url?: string;
b64_json?: string;
};
const url =
first.url ||
(first.b64_json
? `data:image/png;base64,${first.b64_json}`
: undefined);
if (!url) {
throw new HttpError(
400,
'Together AI response did not include an image URL',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'together' },
},
);
}
return url;
}
#getModel(model?: string) {
+1
View File
@@ -63,6 +63,7 @@ export interface IGenerateParams {
input_image?: string;
input_image_mime_type?: string;
input_images?: string[];
puter_output_path?: string;
[key: string]: unknown;
}
@@ -317,7 +317,7 @@ describe('VoiceChangerDriver.convert success path', () => {
// ── Error mapping ───────────────────────────────────────────────────
describe('VoiceChangerDriver.convert error mapping', () => {
it('rethrows the upstream status when ElevenLabs returns an error body', async () => {
it('maps upstream 4xx to HttpError upstream_bad_request (keeps the upstream status)', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response(
@@ -336,9 +336,32 @@ describe('VoiceChangerDriver.convert error mapping', () => {
voice_id: 'missing-voice',
}),
),
).rejects.toMatchObject({ statusCode: 404 });
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'upstream_bad_request',
});
// No metering should be recorded on a failed call.
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable (skips alert)', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('boom', { status: 503 }),
);
await expect(
withActor(actor, () =>
driver.convert({
audio: dataUrl(Buffer.from('x'), 'audio/mpeg'),
voice_id: 'any-voice',
}),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -229,8 +229,32 @@ export class VoiceChangerDriver extends PuterDriver {
detail && typeof detail === 'object' && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `ElevenLabs returned ${response.status}`;
throw new HttpError(response.status, message, {
legacyCode: 'internal_error',
// Tag upstream status as `upstream_*` so the alarm gate
// skips paging on ElevenLabs 5xx outages (we expose them
// as 400 like the TTS provider does — user can't act on
// them, but it's not our bug either).
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: legacyCode === 'upstream_provider_unavailable'
? 400
: response.status;
throw new HttpError(exposedStatus, message, {
legacyCode,
fields: {
provider: 'elevenlabs',
upstreamStatus: response.status,
},
});
}
@@ -213,7 +213,7 @@ export class SpeechToTextDriver extends PuterDriver {
this.services.fs,
actor,
args.file,
{ maxBytes: MAX_AUDIO_FILE_SIZE },
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
);
const selectedModel =
@@ -215,32 +215,45 @@ export class XAISpeechToTextDriver extends PuterDriver {
formData.append('file', blob, filename);
}
let response: Response;
try {
response = await fetch(`${API_BASE}/stt`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
},
body: formData,
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[XAISpeechToTextDriver] API error:', msg);
throw new HttpError(502, `xAI STT API error: ${msg}`, {
legacyCode: 'internal_error',
});
}
const response = await fetch(`${API_BASE}/stt`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
},
body: formData,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[XAISpeechToTextDriver] API returned ${response.status}: ${errText}`,
);
// Mirrors ElevenLabs / XAITTS — map upstream status to an
// `upstream_*` HttpError so the alarm gate skips it.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`xAI STT API error (${response.status}): ${errText}`,
{ legacyCode: 'internal_error' },
exposedStatus,
errText || `xAI STT request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'xai',
upstreamStatus: response.status,
},
},
);
}
@@ -420,7 +420,7 @@ describe('ElevenLabsTTSProvider.synthesize metering', () => {
// ── Error paths ─────────────────────────────────────────────────────
describe('ElevenLabsTTSProvider.synthesize error paths', () => {
it('wraps non-OK upstream responses as HttpError 502', async () => {
it('wraps non-OK upstream 4xx responses as HttpError 400 with upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('{"error":"bad voice"}', {
@@ -431,7 +431,10 @@ describe('ElevenLabsTTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
@@ -100,12 +100,44 @@ export class ElevenLabsTTSProvider extends TTSProvider {
status: response.status,
detail,
});
// Map upstream status to an `upstream_*` HttpError so the alarm
// gate skips it. Anything 4xx from ElevenLabs (voice_not_found,
// invalid model, bad payload, auth) is a user-caused error from
// our perspective — expose as 400. 5xx is an outage on their
// side — also expose as 400 (`upstream_provider_unavailable`)
// since the user can't act on it but it's not our bug.
const upstreamCode =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(detail as any)?.detail?.code ?? (detail as any)?.code;
const upstreamMessage =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(detail as any)?.detail?.message ?? (detail as any)?.message;
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`ElevenLabs request failed (status ${response.status})`,
exposedStatus,
upstreamMessage ??
`ElevenLabs request failed (status ${response.status})`,
{
legacyCode: 'internal_error',
fields: { provider: 'elevenlabs', status: response.status },
legacyCode,
fields: {
provider: 'elevenlabs',
upstreamStatus: response.status,
upstreamCode,
},
},
);
}
@@ -476,17 +476,21 @@ describe('GeminiTTSProvider.synthesize error paths', () => {
}
});
it('wraps SDK errors as HttpError 502', async () => {
it('lets SDK errors bubble untouched so the driver boundary can classify them', async () => {
const provider = makeProvider();
generateContentMock.mockRejectedValueOnce(new Error('upstream blew up'));
// Google GenAI `ApiError`s carry `.status` — surfacing them
// through the driver-boundary translator yields a proper
// `upstream_*` HttpError instead of a 500/502 page.
const apiError = Object.assign(new Error('bad voice'), { status: 400 });
generateContentMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({ status: 400, message: 'bad voice' });
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
it('throws 502 when Gemini response has no inline audio data', async () => {
it('throws 400 upstream_bad_request when Gemini response has no inline audio data', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce({
candidates: [{ content: { parts: [{ text: 'no audio here' }] } }],
@@ -495,7 +499,10 @@ describe('GeminiTTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -229,35 +229,29 @@ export class GeminiTTSProvider extends TTSProvider {
? `${instructions}\n\nSay the following text aloud:\n${text}`
: `Say the following text aloud:\n${text}`;
// Let Google GenAI `ApiError`s bubble — they carry `.status` and
// are mapped to `upstream_*` HttpErrors by the driver-boundary
// translator. Catching here and wrapping as 502 hid the upstream
// status and caused 4xx validation errors to page.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await this.#client.models.generateContent({
model,
contents: [{ parts: [{ text: inputText }] }],
config: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: voice },
},
const response: any = await this.#client.models.generateContent({
model,
contents: [{ parts: [{ text: inputText }] }],
config: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: voice },
},
},
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[GeminiTTSProvider] API error:', msg);
throw new HttpError(502, `Gemini TTS API error: ${msg}`, {
legacyCode: 'internal_error',
fields: { provider: 'gemini' },
});
}
},
});
// Extract audio data from response
const part = response?.candidates?.[0]?.content?.parts?.[0];
if (!part?.inlineData?.data) {
throw new HttpError(502, 'Gemini TTS did not return audio data', {
legacyCode: 'internal_error',
throw new HttpError(400, 'Gemini TTS did not return audio data', {
legacyCode: 'upstream_bad_request',
fields: { provider: 'gemini' },
});
}
@@ -113,7 +113,7 @@ describe('XAITTSProvider catalog', () => {
expect(engines[0]).toMatchObject({
id: 'xai-tts',
provider: 'xai',
pricing_per_million_chars: 420,
pricing_per_million_chars: 1500,
});
});
});
@@ -327,7 +327,7 @@ describe('XAITTSProvider.synthesize metering', () => {
// ── Error paths ─────────────────────────────────────────────────────
describe('XAITTSProvider.synthesize error paths', () => {
it('wraps non-OK upstream responses as HttpError 502', async () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('bad request', { status: 400 }),
@@ -335,17 +335,55 @@ describe('XAITTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('wraps fetch network errors as HttpError 502 (not a thrown ENOTFOUND)', async () => {
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('oops', { status: 503 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('slow down', { status: 429 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
const provider = makeProvider();
// Raw network errors don't carry an upstream status — we
// intentionally don't wrap them here. The driver-boundary
// catch-all will surface them as a generic 500 (which we
// *do* want to alert on, since "we can't even reach the
// provider" usually means something on our side is wrong).
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toThrow('connection reset');
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -79,7 +79,7 @@ export class XAITTSProvider extends TTSProvider {
id: 'xai-tts',
name: 'xAI TTS',
provider: 'xai',
pricing_per_million_chars: 420,
pricing_per_million_chars: 1500,
},
];
}
@@ -154,34 +154,48 @@ export class XAITTSProvider extends TTSProvider {
body.output_format = { codec };
}
let response: Response;
try {
response = await fetch(`${API_BASE}/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[XAITTSProvider] API error:', msg);
throw new HttpError(502, `xAI TTS API error: ${msg}`, {
legacyCode: 'internal_error',
fields: { provider: 'xai' },
});
}
const response = await fetch(`${API_BASE}/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[XAITTSProvider] API returned ${response.status}: ${errText}`,
);
// Map upstream status to an `upstream_*` HttpError so the
// alarm gate skips it. Mirrors ElevenLabs' translator —
// 4xx and 5xx both surface as 400 to the client (with the
// appropriate legacyCode), 429 stays 429, auth stays 500.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`xAI TTS API error (${response.status}): ${errText}`,
{ legacyCode: 'internal_error', fields: { provider: 'xai' } },
exposedStatus,
errText || `xAI TTS request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'xai',
upstreamStatus: response.status,
},
},
);
}
@@ -17,18 +17,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// xAI TTS pricing: $4.20 per 1M characters
// = 420 cents per 1M characters
// = 0.00042 cents per character
// = 0.42 microcents per character
// We store microcents per character (cents * 1_000_000 / 1_000_000 chars).
// $4.20 / 1M chars = 420 cents / 1M chars = 0.00042 cents/char = 0.42 ucents/char
// But metering uses integer microcents, so we round up to 1 microcent per char
// to avoid undercharging. More precisely: 420 * 1_000_000 / 1_000_000 = 420 ucents per char? No.
// Let's be precise:
// $4.20 per 1M chars = 420 cents per 1M chars
// In microcents: 420 * 1_000_000 = 420_000_000 microcents per 1M chars
// Per character: 420_000_000 / 1_000_000 = 420 microcents per character
// xAI TTS pricing (per xAI docs, Voice pricing table): $15.00 per 1M characters
// $15.00 per 1M chars = 1500 cents per 1M chars
// In microcents: 1500 * 1_000_000 = 1_500_000_000 microcents per 1M chars
// Per character: 1_500_000_000 / 1_000_000 = 1500 microcents per character
export const XAI_TTS_COSTS: Record<string, number> = {
'xai-tts': 420,
'xai-tts': 1500,
};
@@ -448,3 +448,186 @@ describe('VideoGenerationDriver metering propagation', () => {
expect(usageType).toMatch(/^openai:sora-2:/);
});
});
// ── puter_output_path ─────────────────────────────────────────────
describe('VideoGenerationDriver.generate puter_output_path', () => {
const TEST_ACTOR: import('../../core/actor.js').Actor = {
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
};
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
it('throws 400 when puter_output_path is root', async () => {
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('throws 400 when puter_output_path parent is root (e.g. /video.mp4)', async () => {
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/video.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('throws 403 when ACL denies write access', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(false);
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('ACL check runs BEFORE provider.generate so credits are not wasted', async () => {
const callOrder: string[] = [];
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockImplementation(async () => {
callOrder.push('acl');
return false;
});
openaiVideosCreateMock.mockImplementation(async () => {
callOrder.push('provider');
return openaiCompletedJob();
});
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(callOrder).toEqual(['acl']);
});
it('resolves ~ in puter_output_path to /<username>/', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '~/videos/clip.mp4',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
).toBe('/testuser/videos/clip.mp4');
});
it('writes stream result to FS and returns a new stream to caller', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
const result = await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(userId).toBe(42);
const meta = (
writeArg as {
fileMetadata: {
path: string;
contentType: string;
overwrite: boolean;
};
}
).fileMetadata;
expect(meta.path).toBe('/testuser/videos/clip.mp4');
expect(meta.overwrite).toBe(true);
expect(result).toBeDefined();
});
it('does not forward puter_output_path to the upstream provider call', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
expect(sent.puter_output_path).toBeUndefined();
});
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
const noIdActor: import('../../core/actor.js').Actor = {
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
};
await expect(
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/noone/dir/clip.mp4',
} as never),
),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
});
@@ -17,8 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
@@ -114,12 +118,34 @@ export class VideoGenerationDriver extends PuterDriver {
}
async generate(args: IGenerateVideoParams) {
const actor = Context.get('actor');
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;
// Validate the output path early — before spending credits.
let resolvedOutputPath: string | undefined;
if (puterOutputPath) {
const username = actor.user?.username;
const userId = actor.user?.id;
if (!userId || !username) {
throw new HttpError(
400,
'User ID required for puter_output_path',
{ legacyCode: 'bad_request' },
);
}
resolvedOutputPath = this.#resolveOutputPath(
puterOutputPath,
username,
);
await this.#assertWriteAccess(actor, resolvedOutputPath);
}
if (args.model) {
args.model = args.model.trim().toLowerCase();
}
@@ -202,11 +228,17 @@ export class VideoGenerationDriver extends PuterDriver {
args.resolution = normalizedResolution;
}
return await provider.generate({
const result = await provider.generate({
...args,
model: model.id,
provider: model.provider,
});
if (resolvedOutputPath) {
return await this.#saveToFS(actor, result, resolvedOutputPath);
}
return result;
}
// -- Provider registration -----------------------------------------------
@@ -350,6 +382,135 @@ export class VideoGenerationDriver extends PuterDriver {
}
}
async #saveToFS(
actor: Actor,
result: unknown,
resolvedPath: string,
): Promise<unknown> {
const userId = actor.user!.id!;
let buffer: Buffer;
let contentType: string;
if (typeof result === 'string') {
if (result.startsWith('data:')) {
const commaIdx = result.indexOf(',');
const header = result.substring(0, commaIdx);
contentType = header.match(/data:(.*?);/)?.[1] ?? 'video/mp4';
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
} else {
const response = await fetch(result);
if (!response.ok) {
throw new HttpError(
502,
`Failed to fetch generated video for FS write: ${response.status}`,
{ legacyCode: 'internal_error' },
);
}
contentType =
response.headers.get('content-type') ?? 'video/mp4';
buffer = Buffer.from(await response.arrayBuffer());
}
} else if (result && typeof result === 'object' && 'stream' in result) {
const streamResult = result as {
stream: Readable;
content_type: string;
};
contentType = streamResult.content_type || 'video/mp4';
const chunks: Buffer[] = [];
for await (const chunk of streamResult.stream) {
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
);
}
buffer = Buffer.concat(chunks);
} else {
throw new HttpError(
500,
'Unsupported video result format for puter_output_path',
{ legacyCode: 'internal_error' },
);
}
await this.services.fs.write(userId, {
fileMetadata: {
path: resolvedPath,
size: buffer.length,
contentType,
overwrite: true,
createMissingParents: true,
},
fileContent: Readable.from(buffer),
});
// For stream results, reconstruct a new stream from the buffered data
if (typeof result !== 'string') {
return {
stream: Readable.from(buffer),
content_type: contentType,
};
}
return result;
}
#resolveOutputPath(outputPath: string, username: string): string {
let resolved = outputPath.trim();
if (resolved === '~' || resolved.startsWith('~/')) {
resolved = `/${username}${resolved.slice(1)}`;
}
assertNormalized(resolved);
if (!resolved.startsWith('/')) {
resolved = `/${resolved}`;
}
if (resolved.length > 1 && resolved.endsWith('/')) {
resolved = resolved.slice(0, -1);
}
return resolved;
}
async #assertWriteAccess(
actor: Actor,
resolvedPath: string,
): Promise<void> {
if (resolvedPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const parentPath = pathPosix.dirname(resolvedPath);
if (parentPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const pathToCheck = parentPath;
const fsService = this.services.fs;
let ancestorsCache: Promise<
Array<{ uid: string; path: string }>
> | null = null;
const canWrite = await this.services.acl.check(
actor,
{
path: pathToCheck,
resolveAncestors() {
if (!ancestorsCache) {
ancestorsCache =
fsService.getAncestorChain(pathToCheck);
}
return ancestorsCache;
},
},
'write',
);
if (!canWrite) {
throw new HttpError(403, 'Write access denied for destination', {
legacyCode: 'access_denied',
});
}
}
#resolveModel(modelId: string, provider?: string): IVideoModel | null {
const models = this.#modelIdMap[modelId?.trim().toLowerCase()];
if (!models || models.length === 0) return null;
@@ -362,7 +362,7 @@ describe('OpenAIVideoProvider.generate polling', () => {
}
});
it('throws when the polled job ends in failed state', async () => {
it('surfaces failed jobs as HttpError 400 upstream_failed (not a 500 page)', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
@@ -376,7 +376,11 @@ describe('OpenAIVideoProvider.generate polling', () => {
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toThrow('content policy violation');
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_failed',
message: 'content policy violation',
});
expect(videosDownloadContentMock).not.toHaveBeenCalled();
});
});
@@ -134,7 +134,14 @@ export class OpenAIVideoProvider extends VideoProvider {
if (finalJob.status === 'failed') {
const errorMessage =
finalJob.error?.message ?? 'Video generation failed';
throw new Error(errorMessage);
// Same reasoning as TogetherVideoProvider — Sora's `failed`
// status covers both user input issues (content policy) and
// their own outages; expose as `upstream_failed` 400 so the
// alarm gate skips it instead of paging on 500.
throw new HttpError(400, errorMessage, {
legacyCode: 'upstream_failed',
fields: { provider: 'openai' },
});
}
const finalResolution =
@@ -409,7 +409,7 @@ describe('TogetherVideoProvider.generate polling', () => {
}
});
it('throws HttpError 500 on failed jobs with the surfaced error message', async () => {
it('surfaces failed jobs as HttpError 400 upstream_failed (not 500) so they do not page', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({ id: 'job-3' });
videosRetrieveMock.mockResolvedValueOnce({
@@ -421,7 +421,8 @@ describe('TogetherVideoProvider.generate polling', () => {
await expect(
withTestActor(() => provider.generate({ prompt: 'hi' })),
).rejects.toMatchObject({
statusCode: 500,
statusCode: 400,
legacyCode: 'upstream_failed',
message: 'content policy violation',
});
});
@@ -195,8 +195,14 @@ export class TogetherVideoProvider extends VideoProvider {
finalJob?.info?.errors?.message ??
finalJob?.info?.errors ??
'Video generation failed';
throw new HttpError(500, errorMessage, {
legacyCode: 'internal_error',
// Together returns `failed` for both user-input issues
// (content policy / unsupported params) and their own
// outages — we can't reliably tell from the payload, so
// expose as 4xx with `upstream_failed`. The alarm gate
// skips `upstream_*` legacy codes so this no longer pages.
throw new HttpError(400, errorMessage, {
legacyCode: 'upstream_failed',
fields: { provider: 'together' },
});
}
+1
View File
@@ -68,6 +68,7 @@ export interface IGenerateVideoParams {
metadata?: object;
input_reference?: unknown;
no_extra_params?: boolean;
puter_output_path?: string;
}
export interface IVideoProvider {
+7 -4
View File
@@ -157,13 +157,16 @@ export class AppDriver extends PuterDriver {
if (await this.appStore.existsByName(fields.name)) {
if (options?.dedupe_name) {
let candidate;
let n = 1;
let i = 0;
do {
candidate = `${fields.name}-${++n}`;
if (n > 50)
const randString = Math.random().toString(36).slice(2, 6);
candidate = `${fields.name}-${randString}`;
if (i >= 3)
throw new HttpError(400, 'Failed to dedupe app name', {
legacyCode: 'bad_request',
legacyCode: 'app_name_already_in_use',
});
i++;
} while (await this.appStore.existsByName(candidate));
fields.name = candidate;
} else {
+8 -8
View File
@@ -64,13 +64,13 @@ const meta = (
describe('KVStoreDriver — rate-limit policy', () => {
const m = meta(new KVStoreDriver(...fake()));
it('pins the kv tier values (registered 200 / 10s, temp 100 / 10s)', () => {
it('pins the kv tier values (registered 400 / 10s, temp 200 / 10s)', () => {
expect(m.rateLimit?.default).toEqual({
limit: 200,
limit: 400,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
[DEFAULT_FREE_SUBSCRIPTION]: 400,
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
},
});
});
@@ -98,13 +98,13 @@ describe('AppDriver — rate-limit policy', () => {
describe('SubdomainDriver — rate-limit policy', () => {
const m = meta(new SubdomainDriver(...fake()));
it('matches AppDriver tier values (subdomain CRUD is similarly cheap)', () => {
it('pins the subdomain tier values (registered 200 / 10s, temp 100 / 10s)', () => {
expect(m.rateLimit?.default).toEqual({
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 100,
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
},
});
});
+3 -3
View File
@@ -51,11 +51,11 @@ export class KVStoreDriver extends PuterDriver {
// (no subscription resolution) is not given the tighter cap.
readonly rateLimit: DriverRateLimitConfig = {
default: {
limit: 200,
limit: 400,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
[DEFAULT_FREE_SUBSCRIPTION]: 400,
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
},
},
};
@@ -491,3 +491,147 @@ describe('SubdomainDriver.delete', () => {
).rejects.toMatchObject({ statusCode: 404 });
});
});
// ── associated_app derivation (security fix) ───────────────────────
//
// `associated_app_uid` was previously a user-writable field on the
// subdomain row, with no check that the targeted app belonged to the
// caller. That let an attacker bind their own subdomain to another
// user's app (notably a private app), tricking the hosted-site
// middleware into running the entitlement gate for the victim's app on
// the attacker's subdomain. The field is now ignored on writes and
// derived on reads from `apps.owner_user_id = subdomain.user_id` plus
// an `index_url` match against the subdomain's host variants.
const createAppWithIndexUrl = async (
ownerUserId: number,
indexUrl: string,
opts: { isPrivate?: boolean } = {},
): Promise<{ id: number; uid: string }> => {
const uid = `app-${uuidv4()}`;
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?)`,
[
uid,
`app-${uid}`,
`app-${uid}`,
indexUrl,
ownerUserId,
opts.isPrivate ? 1 : 0,
],
);
const row = (
await server.clients.db.read('SELECT id, uid FROM apps WHERE uid = ?', [
uid,
])
)[0] as { id: number; uid: string };
return row;
};
describe('SubdomainDriver associated_app derivation', () => {
it('ignores `associated_app_uid` on create and derives null when no app matches', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const sub = uniqueSubdomain('assoc-create');
// Caller asserts an arbitrary app uid — must be silently dropped.
const result = (await withActor(actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${username}/Public`,
associated_app_uid: 'app-does-not-exist',
},
}),
)) as Record<string, unknown>;
expect(result.associated_app).toBeNull();
const row = await server.stores.subdomain.getBySubdomain(sub);
expect(row?.associated_app_id).toBeFalsy();
});
it('ignores `associated_app_uid` on update', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const sub = uniqueSubdomain('assoc-update');
const created = (await withActor(actor, () =>
driver.create({
object: { subdomain: sub, root_dir: `/${username}/Public` },
}),
)) as Record<string, unknown>;
// Plant an app owned by another user that the index_url-derive
// would otherwise reject anyway. Even with the (now-defunct)
// explicit `associated_app_uid` knob, the field must stay null.
const other = await makeUser();
const otherApp = await createAppWithIndexUrl(
other.userId,
'http://anything.example.test/',
);
const updated = (await withActor(actor, () =>
driver.update({
uid: created.uid,
object: { associated_app_uid: otherApp.uid },
}),
)) as Record<string, unknown>;
expect(updated.associated_app).toBeNull();
const row = await server.stores.subdomain.getBySubdomain(sub);
expect(row?.associated_app_id).toBeFalsy();
// user_id should still be the original owner.
expect(row?.user_id).toBe(userId);
});
it("derives `associated_app` from the owner's app whose index_url matches the subdomain host", async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const sub = uniqueSubdomain('assoc-derive');
// App owned by the same user, registered with a URL on the
// default test hosting domain. The derive logic crosses host
// variants × protocols × paths, so any reasonable index_url
// anchored at the subdomain's name should match.
const indexUrl = `http://${sub}.site.puter.localhost/`;
const app = await createAppWithIndexUrl(userId, indexUrl);
const created = (await withActor(actor, () =>
driver.create({
object: { subdomain: sub, root_dir: `/${username}/Public` },
}),
)) as Record<string, unknown>;
const associated = created.associated_app as {
uid: string;
} | null;
expect(associated?.uid).toBe(app.uid);
});
it("does not derive an `associated_app` for another user's app at the same host", async () => {
// The core IDOR: attacker (a) sets up a subdomain whose host
// matches the index_url of a victim's (b) private app. Even on
// exact index_url match, the ownership filter must reject the
// app from another user's account.
const a = await makeUser();
const b = await makeUser();
const sub = uniqueSubdomain('idor');
await createAppWithIndexUrl(
b.userId,
`http://${sub}.site.puter.localhost/`,
{ isPrivate: true },
);
const created = (await withActor(a.actor, () =>
driver.create({
object: {
subdomain: sub,
root_dir: `/${a.actor.user!.username}/Public`,
},
}),
)) as Record<string, unknown>;
expect(created.associated_app).toBeNull();
});
});
+155 -47
View File
@@ -81,11 +81,11 @@ export class SubdomainDriver extends PuterDriver {
// same shape — the three crud-q drivers share one envelope.
readonly rateLimit: DriverRateLimitConfig = {
default: {
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 100,
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
},
},
};
@@ -141,12 +141,16 @@ export class SubdomainDriver extends PuterDriver {
});
}
await this.services.fs.checkFSAccess(entry, actor);
const associatedAppId = await this.#resolveAssociatedAppId(object);
// `associated_app_id` is no longer accepted from clients. The
// "associated app" for a subdomain is derived at read time from
// `apps.owner_user_id = subdomain.user_id` + `index_url` match
// (see `#hydrateRows`), so a subdomain row can never assert an
// association with an app the caller doesn't own.
const created = await this.stores.subdomain.create({
userId: actor.user.id,
subdomain,
rootDirId,
associatedAppId,
associatedAppId: null,
appOwner: actor.app?.id ?? null,
});
const [shaped] = await this.#hydrateRows(
@@ -234,9 +238,9 @@ export class SubdomainDriver extends PuterDriver {
}
patch.root_dir_id = rootDirId;
}
if (object.associated_app_uid !== undefined)
patch.associated_app_id =
await this.#resolveAssociatedAppId(object);
// `associated_app_uid` is silently ignored on update — the field is
// derived at read time (see `#hydrateRows`). Same rationale as
// `create`: no parallel source of truth that the system can't verify.
if (object.domain !== undefined)
patch.domain = object.domain != null ? String(object.domain) : null;
@@ -496,32 +500,6 @@ export class SubdomainDriver extends PuterDriver {
}
}
// ── Input resolution ────────────────────────────────────────────
//
// Clients send `associated_app_uid` (the app's public UUID), never
// a raw mysql id. v1 used the OM `reference` mapping to do the
// same translation under the hood; v2 does it explicitly.
async #resolveAssociatedAppId(
object: Record<string, unknown>,
): Promise<number | null> {
const uid = object.associated_app_uid;
if (uid == null) return null;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`associated_app_uid` must be a string', {
legacyCode: 'bad_request',
});
}
const app = (await this.stores.app.getByUid(uid)) as {
id: number;
} | null;
if (!app) {
throw new HttpError(400, '`associated_app_uid` does not exist', {
legacyCode: 'bad_request',
});
}
return app.id;
}
// ── Config ──────────────────────────────────────────────────────
#configMaxSubdomains(): number {
@@ -565,7 +543,7 @@ export class SubdomainDriver extends PuterDriver {
if (rows.length === 0) return [];
const collectIds = (
key: 'user_id' | 'root_dir_id' | 'associated_app_id' | 'app_owner',
key: 'user_id' | 'root_dir_id' | 'app_owner',
): number[] => {
const out = new Set<number>();
for (const r of rows) {
@@ -581,12 +559,18 @@ export class SubdomainDriver extends PuterDriver {
const userIds = collectIds('user_id');
const rootDirIds = collectIds('root_dir_id');
const appIds = [
...new Set([
...collectIds('associated_app_id'),
...collectIds('app_owner'),
]),
const appOwnerIds = collectIds('app_owner');
// `associated_app` is derived: match apps owned by `subdomain.user_id`
// whose `index_url` resolves to one of the subdomain's host
// variants. The row's stored `associated_app_id` is ignored
// (was user-writable without an ownership check).
const associatedAppIdByRowUuid =
await this.#deriveAssociatedAppIdByRowUuid(rows);
const associatedAppIds = [
...new Set(associatedAppIdByRowUuid.values()),
];
const allAppIds = [...new Set([...associatedAppIds, ...appOwnerIds])];
// Single round-trip per store, all in parallel — the filetype
// lookup keys off the requested ids (not on getByIds' result),
@@ -595,8 +579,8 @@ export class SubdomainDriver extends PuterDriver {
await Promise.all([
this.stores.user.getByIds(userIds),
this.stores.fsEntry.getEntriesByIds(rootDirIds),
this.stores.app.getByIds(appIds),
this.stores.app.getFiletypeAssociationsByIds(appIds),
this.stores.app.getByIds(allAppIds),
this.stores.app.getFiletypeAssociationsByIds(allAppIds),
]);
return rows.map((row) =>
@@ -605,10 +589,135 @@ export class SubdomainDriver extends PuterDriver {
entriesById,
appsById,
filetypesByAppId,
associatedAppIdByRowUuid,
}),
);
}
/**
* For each subdomain row, find the app owned by the same user whose
* `index_url` matches one of the subdomain's host candidates
* (subdomain × hosting domains × protocols × paths). Returns a
* `rowUuid → appId` map. Rows with no matching app are absent.
*
* Runs one batched DB query regardless of input size.
*/
async #deriveAssociatedAppIdByRowUuid(
rows: Array<Record<string, unknown>>,
): Promise<Map<string, number>> {
const result = new Map<string, number>();
if (rows.length === 0) return result;
const normalize = (v: unknown): string | null => {
if (typeof v !== 'string') return null;
const trimmed = v.trim().toLowerCase().replace(/^\./, '');
return trimmed || null;
};
const stripPort = (v: string): string => v.split(':')[0] || v;
const hostingDomainsRaw = [
normalize(this.config.static_hosting_domain),
normalize(this.config.static_hosting_domain_alt),
normalize(this.config.private_app_hosting_domain),
normalize(this.config.private_app_hosting_domain_alt),
].filter((d): d is string => !!d);
const hostingDomains = [
...new Set([
...hostingDomainsRaw,
...hostingDomainsRaw.map(stripPort),
]),
];
if (hostingDomains.length === 0) return result;
const configuredProtocol =
typeof this.config.protocol === 'string'
? this.config.protocol.trim().replace(/:$/, '')
: '';
const protocols = [
...new Set(
[configuredProtocol, 'https', 'http'].filter((p) => !!p),
),
];
const userIdToRowMeta = new Map<
number,
Array<{ rowUuid: string; candidates: Set<string> }>
>();
const allCandidates = new Set<string>();
for (const row of rows) {
const subdomain =
typeof row.subdomain === 'string'
? row.subdomain.toLowerCase()
: '';
const userId =
typeof row.user_id === 'number'
? row.user_id
: Number(row.user_id);
const rowUuid =
typeof row.uuid === 'string' && row.uuid.length > 0
? row.uuid
: '';
if (!subdomain || !Number.isFinite(userId) || !rowUuid) continue;
const candidates = new Set<string>();
for (const d of hostingDomains) {
const host = `${subdomain}.${d}`;
for (const p of protocols) {
const base = `${p}://${host}`;
candidates.add(base);
candidates.add(`${base}/`);
candidates.add(`${base}/index.html`);
}
}
for (const c of candidates) allCandidates.add(c);
if (!userIdToRowMeta.has(userId)) {
userIdToRowMeta.set(userId, []);
}
userIdToRowMeta.get(userId)!.push({ rowUuid, candidates });
}
if (allCandidates.size === 0 || userIdToRowMeta.size === 0) {
return result;
}
const userIds = [...userIdToRowMeta.keys()];
const userPlaceholders = userIds.map(() => '?').join(', ');
const candidateList = [...allCandidates];
const urlPlaceholders = candidateList.map(() => '?').join(', ');
const matches = (await this.clients.db.read(
`SELECT \`id\`, \`owner_user_id\`, \`index_url\` FROM \`apps\`
WHERE \`owner_user_id\` IN (${userPlaceholders})
AND \`index_url\` IN (${urlPlaceholders})`,
[...userIds, ...candidateList],
)) as Array<Record<string, unknown>>;
for (const m of matches) {
const appId = typeof m.id === 'number' ? m.id : Number(m.id);
const ownerId =
typeof m.owner_user_id === 'number'
? m.owner_user_id
: Number(m.owner_user_id);
const indexUrl = typeof m.index_url === 'string' ? m.index_url : '';
if (
!Number.isFinite(appId) ||
!Number.isFinite(ownerId) ||
!indexUrl
) {
continue;
}
const rowsForOwner = userIdToRowMeta.get(ownerId) ?? [];
for (const { rowUuid, candidates } of rowsForOwner) {
if (candidates.has(indexUrl) && !result.has(rowUuid)) {
result.set(rowUuid, appId);
}
}
}
return result;
}
#shapeRow(
row: Record<string, unknown>,
lookups: {
@@ -616,6 +725,7 @@ export class SubdomainDriver extends PuterDriver {
entriesById: Map<number, FSEntry>;
appsById: Map<number, Record<string, unknown>>;
filetypesByAppId: Map<number, string[]>;
associatedAppIdByRowUuid: Map<string, number>;
},
): Record<string, unknown> {
const ts = row.ts;
@@ -641,11 +751,9 @@ export class SubdomainDriver extends PuterDriver {
: null;
const associatedAppRefId =
row.associated_app_id == null
? null
: typeof row.associated_app_id === 'number'
? row.associated_app_id
: Number(row.associated_app_id);
typeof row.uuid === 'string'
? (lookups.associatedAppIdByRowUuid.get(row.uuid) ?? null)
: null;
const associatedApp =
associatedAppRefId != null
? (lookups.appsById.get(associatedAppRefId) ?? null)
+2 -2
View File
@@ -37,7 +37,7 @@ import { AI_CONCURRENT, AI_RATE_LIMIT } from './aiLimits.js';
describe('AI_RATE_LIMIT', () => {
it('pins the documented tier values', () => {
expect(AI_RATE_LIMIT.default).toEqual({
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 30,
@@ -75,7 +75,7 @@ describe('AI_RATE_LIMIT', () => {
describe('AI_CONCURRENT', () => {
it('pins the documented tier values', () => {
expect(AI_CONCURRENT.default).toEqual({
limit: 10,
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 3,
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more