Compare commits

..
97 Commits
Author SHA1 Message Date
tamilselvanp57 a4bb57bb79 Fix static hosting domain fallback for self-hosted custom domains (#3382) 2026-07-15 11:45:10 -07:00
Daniel Salazar 861cf5efb8 feat: email api wip (#3391) 2026-07-15 11:30:16 -07:00
Daniel Salazar cf34f9fea9 fix: sanitize get user response (#3390)
* fix: sanitize get user response

* fix: app creation in dev center
2026-07-15 02:02:06 -07:00
jelveh 92a6d30e26 Add calendar to recommended apps 2026-07-14 23:04:24 -07:00
Neal ShahandDaniel Salazar 23e8705b03 Implement workers without cloudflare for local dev testing (#3389)
* typeify subdomains wip

* initial (untested) logic for LocalWorkerService

* Make it work, add lifecycle expiry since workers are process heavy in current implementation

* fix type errors

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-07-15 01:01:42 -04:00
Daniel Salazar 3240a4670a fix: admin gates (#3386) 2026-07-14 12:58:04 -07:00
Daniel Salazar 3bf83d40e9 fix: ui signup order (#3385) 2026-07-13 18:47:11 -07:00
Neal Shah c3368f553e Add GUI button for custom OIDC (#3384) 2026-07-13 20:32:37 -04:00
velzie 27def94d8b feat: support cross-origin-isolated login (#3338) 2026-07-13 17:36:56 -04:00
Neal Shah 2c139929ac Check if user is suspended on WebDAV basic auth and others (#3369) 2026-07-13 02:46:28 -04:00
Neal Shah b957f797da add support for ndjson logging (#3381)
* add support for ndjson logging

* move install location
2026-07-13 02:43:56 -04:00
jelveh 393500d565 Add calculator to recommended apps
Include 'calculator' in RECOMMENDED_APP_NAMES within RecommendedAppsService.ts so the calculator app is included in the recommended apps list.
2026-07-11 23:04:39 -07:00
jelveh a108837e82 Revert "Add delayed centered spinner for apps tab"
This reverts commit 859e88753f.
2026-07-11 15:21:59 -07:00
jelveh 859e88753f Add delayed centered spinner for apps tab
Show a subtle centered loading spinner for the Apps dashboard when fetching apps if the request is slow. Implements a 1.5s delay before showing the spinner and guarantees a minimum 1.5s visible time to avoid blinking; the spinner is only shown when the container is empty (so background refreshes keep existing tiles). Adds a finish helper to clear the spinner timer and await the minimum visible time before rendering results or errors. Also adds CSS for the spinner element and its animations.
2026-07-11 15:16:04 -07:00
Daniel Salazar 5160b44c4b fix: missing OIDC error messages (#3377)
* fix: missing OIDC error messages

* fix: card verification user prefill out for subs
2026-07-11 08:18:13 -07:00
Daniel Salazar 17a9485fb2 Add spans to drivers and high frequency paths (#3376) 2026-07-10 23:15:09 -07:00
Daniel Salazar d3c44f3d6c fix: cache healthcheck in mem for each node (#3375) 2026-07-10 17:41:09 -07:00
Neal Shah d49ba5281d add support for ignoring specific issues in healthcheck (#3374)
* add support for ignoring specific issues in healthcheck

* add mark-degraded to healthcheck
2026-07-10 17:08:03 -04:00
jelveh c107c80bd8 Open dashboard links at #home anchor 2026-07-10 13:22:58 -07:00
Daniel Salazar cf90e775bf fingerprint: use device info for rate limit too (#3372) 2026-07-09 20:22:56 -07:00
Daniel Salazar 0a46096c41 fix: ai rate limits (#3371) 2026-07-09 16:35:03 -07:00
Daniel Salazar 289a44ae79 fix: OIDC state (#3370) 2026-07-09 14:47:34 -07:00
Neal Shah 827e9bc343 fix possible XSS entrypoints (#3368) 2026-07-09 17:19:55 -04:00
Daniel Salazar f2ffaeb823 fix: add phone errors into kv for debugging (#3367) 2026-07-09 12:25:17 -07:00
Neal Shah 6f594f4b60 add 5.6 models (#3366) 2026-07-09 14:46:57 -04:00
jelveh 326be042f1 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 22:33:20 -07:00
jelveh 5b824fb745 Extract resolveTileDisplay and broaden app search
Move logic that determines a tile's displayed title and target link into resolveTileDisplay (use hostname/index_url for external apps) and have buildTileHtml consume it. Expose data-target-link on tiles. Update app search to match the displayed title, raw title, name, and uid so queries find website-shortcut tiles and external apps consistently (matching Home tab behavior).
2026-07-08 22:33:13 -07:00
Reynaldi Chernando cf6cb126b5 fix grok 4.5 release date (#3365) 2026-07-09 12:30:39 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 302df1edd2 update user pays model docs (#3364)
* update user pays model docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-09 08:54:04 +07:00
jelveh e212509ed3 Avoid clipping scaled icons in horizontal scroller
Add a negative top margin and matching top padding to the horizontal scroller to prevent first-row icons from being clipped when they scale on hover. Overflow-x:auto previously caused vertical clipping of scaled icons; margin-top:-8px and padding-top:8px bleed the clip edge upward so scaled icons remain visible without changing scroll behavior.
2026-07-08 18:35:57 -07:00
Daniel Salazar d3e3755d6d chore: cleanup uneeded ai messages (#3363) 2026-07-08 17:55:49 -07:00
jelveh 948ce6e529 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 17:51:38 -07:00
jelveh d117dc231e Implement paged apps launcher UI 2026-07-08 17:50:34 -07:00
Daniel Salazar eefa53e5bd fix: misc auth bug fixes (#3362) 2026-07-08 17:50:22 -07:00
Neal Shah db89fbfc52 add grok 4.5 (#3360) 2026-07-08 14:44:05 -04:00
Daniel Salazar 97ec5d115c fix: double counting purchased credit usage (#3359) 2026-07-07 21:43:09 -07:00
Daniel Salazar 722a6cf50f fix: phone retry limit messagin (#3358) 2026-07-07 21:05:41 -07:00
Neal Shah d039dcfee5 fix appTelemetry (#3357) 2026-07-07 20:10:13 -04:00
Daniel Salazar d09aa11f2e feat: add whatsapp support? (#3356) 2026-07-07 16:13:18 -07:00
jelveh 8a2264546d Always use hash for dashboard tabs
Previously the dashboard used a clean root URL for the 'apps' tab and hashes for other tabs. This change makes tab links and history entries consistently use a hash (e.g. #apps, #home) by always generating href as `#<id>` and pushing `#<section>` to history. Simplifies URL handling and ensures the Apps tab also reflects its hash in the address bar.
2026-07-07 15:16:59 -07:00
0801e1dc44 feat: add signup disable config and fix telemetry startup (#3319)
* feat: add signup disable config and fix telemetry startup

* fix: nits for config spread and 403 checks

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-07 13:52:29 -07:00
Daniel Salazar a04d5fa5e0 fix: package-lock (#3355) 2026-07-07 13:43:15 -07:00
e1280e8cc7 Add custom icon support for web links (#3325)
* feat: add custom web link icon support and context menu option

Introduce the ability to change and display custom icons for .weblink files. Added a "Change Icon" context menu item that invokes the changeWeblinkIcon helper, and updated the icon rendering logic to use getWeblinkIcon instead of the default link icon. This allows users to personalize web link appearance in the file manager.

* Fix security and robustness issues in web link custom icons

 # UIWindowSearch and require the icon data URL body to be pure base64 in
  #isValidWeblinkIcon. A shared/downloaded .weblink is untrusted input and
 # the previous prefix-only check let a crafted icon break out of the
 # <img src> attribute.
 # byte-signature MIME sniffing and capping stored icon size (was storing
 # full-resolution images as base64).
 # without picking a file.
 # weblinks no longer fetches every file's contents on each render.
 # path instead of the target weblink).
 # label, add the AGPL license header, and drop redundant stored icon
 # copies.

* Keep SVG weblink icons as vectors instead of rasterizing

Rasterizing a picked SVG to a 256px PNG threw away its scalability for no
real benefit: an SVG rendered in <img> runs in script-free static mode, and
the base64-body validation already prevents attribute injection. Store SVGs
as data:image/svg+xml;base64 (content-sniffed, size-capped), and keep
rasterizing raster formats and oversized SVGs to bound stored size.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-07 13:41:37 -07:00
Aman Rai 70884c2e30 fix: rename start-gui script to start:gui (#3346) 2026-07-07 12:09:49 -07:00
Reynaldi Chernando 917e95e3d7 Add nano banana 2 lite (#3336) 2026-07-07 12:08:36 -07:00
jelvehandClaude Opus 4.8 27222bbb0d Let ctrl/cmd+f use browser find while dashboard is open
The global ctrl/cmd+f shortcut opened UIWindowSearch and preventDefault'd
the keystroke, blocking the browser's native find. Skip that interception
while a dashboard window is open so ctrl/cmd+f falls through to the browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:43:34 -07:00
jelveh 3720034464 Make Apps the default dashboard tab
Treat the Apps tab as the default (root URL) instead of Home. UIDashboard now reads an initial route from window.dashboard_initial_route?.tab and falls back to 'apps' for unknown/absent routes. Sidebar and section active class logic and tab href/hash generation were updated so 'apps' uses the clean root URL while all other tabs (including home) use #tab. The history.pushState logic was adjusted accordingly. parseDashboardRoute in initgui.js was updated to default to 'apps' and its comment clarified the new behavior.
2026-07-07 09:31:43 -07:00
jelveh de6cab1f9a Reduce myapps tile icon size to 56px
Update .myapps-tile-icon in src/gui/src/css/dashboard.css to change width and height from 60px to 56px, adjusting tile icon sizing for improved layout and spacing.
2026-07-06 21:04:32 -07:00
jelveh 2d1fbc2611 Auto-focus My Apps search on activation
Call focusSearch when the Apps dashboard is activated and add a focusSearch helper. The helper imports isTouchPrimaryDevice and skips focusing on touch-primary devices to avoid showing the on-screen keyboard. It retries up to 12 times (30ms interval) until the search input is visible to handle cases where the dashboard is briefly hidden while entering full-page mode.
2026-07-06 20:10:44 -07:00
jelveh 0afc70218f Use input[type=search] and normalize styling 2026-07-06 20:03:48 -07:00
jelveh 8d3abbc5f0 Increase myapps tile icon size to 60px
Update .myapps-tile-icon width and height from 52px to 60px to improve icon visibility and better match the updated UI design. Other styles (overflow, background, transitions) are left unchanged.
2026-07-06 19:47:17 -07:00
jelveh 7ec14bc7db Add audio-editor and ai-image-project to recommended apps 2026-07-06 19:42:20 -07:00
jelveh 2843010a97 Increase myapps tile size to 100px 2026-07-06 19:37:46 -07:00
jelveh ad4e21ee61 Open external apps via index_url from Apps tab
When an app is external and provides an index_url, use its hostname as the tile title and store the index_url in a new data-target-link attribute. Update the click handler to open the target link in a new tab for external apps, and fall back to opening the internal /app/{name} page for others. This aligns Apps tab behavior with the Home tab and ensures external apps navigate to their website.
2026-07-06 19:21:34 -07:00
Nariman Jelveh 4de39ab6f2 Mark external apps and use the flag for dashboard titles (#3354)
* Mark external apps and use the flag for dashboard titles

An app with no owner_user_id (null/empty) isn't owned by a Puter user —
it's an external, origin-bootstrapped app whose uuid/name/title are all the
opaque app-… id. The dashboard used to detect these client-side by comparing
uuid/name/title and a name.startsWith('app-') check.

Expose an authoritative `external` flag from the API instead:

- /installedApps and /get-launch-apps now return `external`, derived from
  owner_user_id, and no longer leak the raw owner id.
- The dashboard (Home + Apps tabs) shows the index_url hostname for external
  apps based on `external` rather than the uuid/name/title heuristic.

Add/extend extension tests to cover the `external` flag and non-leak.

* Use hostname for opaque external app titles

Detect opaque external app IDs (when name === title === uid/uuid) and replace the displayed title with the hostname from index_url. Adds a uid/uuid fallback and tightens the external-app check in both the Apps and Home dashboard tabs so they behave consistently; preserves target_link for Home entries.

* Add external flag for apps without owner

Expose an `external` property in AppSummary (toAppSummary in RecommendedAppsService.ts) to mark apps that are not owned by a Puter user. The flag is set when `owner_user_id` is null or an empty string, allowing clients to identify origin-bootstrapped/external apps.
2026-07-06 18:00:56 -07:00
Nariman Jelveh fbb646b8eb Return index_url from /installedApps for dashboard app titles (#3352)
The dashboard derives a hostname title for anonymous (app-…) apps whose
uuid/name/title are all identical, falling back to the host of index_url.
That worked for launch/recent apps but not for apps that come only from
/installedApps, because the endpoint didn't return index_url.

Add apps.index_url to the SELECT (alongside apps.icon, relying on the
functional dependency on the apps.id primary key) and assert it in the
extension test.
2026-07-06 16:46:31 -07:00
Neal Shah 46f75ecd6f add type for puter.app.authenticated (#3353) 2026-07-06 18:38:30 -04:00
Daniel Salazar f68caa3bbd feat: allow optional card fallback for failed phone verifications (#3351) 2026-07-06 14:59:55 -07:00
jelveh 0109bf5b72 Use index_url hostname for anonymous app title
Detect anonymous apps whose name/title/uid are identical opaque ids (starting with 'app-') and, when index_url is available, replace the displayed title with the index_url hostname (matching the Home tab behavior). Also persist index_url into the mapped app object and make title a mutable variable to support the change.
2026-07-06 14:43:39 -07:00
Nariman Jelveh c69fb52d5c Make the dashboard the default interface at root (#3345)
* feat: make the dashboard the default interface at root

The dashboard now loads at / (with /dashboard kept as an alias), and the
desktop moves to a new /desktop route. An "Open Desktop" button pinned at
the bottom of the dashboard sidebar switches to the desktop.

Root URLs that carry a desktop-only flow keep booting the desktop:
auth popups (?embedded_in_popup=), app deep links (?app=), direct
downloads (?download=), fullpage mode (?puter.fullpage=), and iframe
embeds.

Desktop-context redirects are retargeted so desktop users stay on the
desktop: logout, the QR "continue on phone" link, the auth cover-page
logo click, and window close/hide URL resets now use /desktop. OIDC
login/signup gains a strictly whitelisted return_to param (/desktop or
/dashboard) so social login returns to the interface it started from.

Also: the dashboard strips ?ref= like the desktop does, ?auth_token=
cleanup preserves the path and tab hash, the logged-out session list
gets the wallpaper back, ?download= also works at /desktop, and the GUI
dev server serves /desktop and /dashboard.

* Add Open Desktop bento card and CSS

Replace the previous desktop-switch UI with a full-width "Open Desktop" bento card that links to /desktop. Removes the pinned sidebar Open Desktop button and the old desktop-switch button/handler, and adds new CSS variables and styles for the desktop card (icon background, shadow, surface/header colors), hover shadow and arrow transition, and layout/padding adjustments.

Changed files: TabHome.js (removed old switch, added anchor card), UIDashboard.js (removed sidebar button), dashboard.css (new desktop color vars and styles; removed legacy desktop-switch/button styles).

* Remove box-shadow from myapps search input

Delete the box-shadow declarations from input.myapps-search::placeholder in dashboard.css. This simplifies the placeholder styling (removes subtle and light shadow layers), likely to reduce visual noise or fix rendering inconsistency.

* Show index_url hostname for opaque app IDs

Only replace an app's title with the hostname from index_url when the app appears to use an opaque id. Add a check for names starting with 'app-' so the hostname is used only if title, name and uuid are identical and the name looks like an opaque app id. This avoids overwriting meaningful user-facing names that may coincidentally match the uuid and clarifies the intent in the comment.

* Update dashboard.css
2026-07-06 13:22:27 -07:00
Neal Shah 3a26f1f1c2 add admin moderation events table (#3348) 2026-07-06 11:49:09 -04:00
Daniel Salazar f1b94a5b69 fix: otel upgrade bump (#3328) 2026-07-06 08:42:25 -07:00
Neal Shah e3fce9191a emit puter.app.authenticated event (#3337) 2026-07-06 11:23:27 -04:00
Nariman Jelveh 2c0a1b9bbb Revert "feat: add sign-in flow for crossOriginIsolated contexts (#3253)" (#3335)
This reverts commit 6fff1fd75f.
2026-06-30 23:32:27 -07:00
jelveh 0d7417f858 Add 'contacts' to recommended apps
Include 'contacts' in the RECOMMENDED_APP_NAMES array so the Contacts app appears in the recommended apps list. Updated src/backend/services/apps/RecommendedAppsService.ts.
2026-06-30 19:08:11 -07:00
Neal Shah 2eee098c66 Add suspension reasons (#3331) 2026-06-30 18:09:00 -04:00
Neal Shah 890d4b3c16 remove temperature from sonnet-5 (#3330) 2026-06-30 17:18:29 -04:00
Neal Shah af9a7c6345 add claude sonnet 5 (#3329) 2026-06-30 17:08:42 -04:00
velzie 6fff1fd75f feat: add sign-in flow for crossOriginIsolated contexts (#3253)
* poc

* route login key through broadcastservice

* cleanup

* security validation and prevent additional webhook sends

* block double-broadcast from redis
2026-06-30 20:17:53 +01:00
velzie e6b7b08bda Add forceRelay option to Peer API and document raw peerconnection property (#3313)
* add forceRelay options

* document peerconnection
2026-06-30 12:28:49 -04:00
Daniel Salazar f4c3cbd53d feat: add logs for failed sms (#3318) 2026-06-30 06:50:47 -07:00
Reynaldi ChernandoandCopilot Autofix powered by AI c0c2f35266 Update docs model drift (#3326)
* Update docs model drift

* Potential fix for pull request finding

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

* standardize

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-30 16:55:42 +07:00
Nikhil Kumar SinghandDevblaze14 ccea980753 feat: add Ideogram 4.0 Together AI image model (#3309)
Co-authored-by: Devblaze14 <[email protected]>
2026-06-29 15:33:01 -04:00
Neal Shah 2bddebd576 add ability to suspend apps (#3322) 2026-06-29 13:06:08 -04:00
Neal Shah b9866ddbeb add suspended_at col (#3321) 2026-06-29 09:51:00 -04:00
Mochammad Fadhlan Al-Ghiffari 6fc79a7184 fix(docs): highlight code blocks after client-side navigation (#3287) (#3288)
* fix(docs): highlight all code blocks after client-side navigation

The docs site highlights code on full page load via an inline
hljs.highlightAll(), which targets the 'pre code' selector and so
highlights every fenced block, including those without a language tag.

The client-side navigation path (pathchange handler) instead selected
only code[class^="language"], so fenced blocks written without a
language hint were left unhighlighted after in-app navigation while
appearing correctly on a hard reload.

Align the pathchange handler with highlightAll() by selecting 'pre code'
and skipping blocks hljs has already processed (data-highlighted=yes),
keeping the pass idempotent across repeated pathchange events.

Fixes #3287

* fix(docs): hoist hljs.configure and scope highlight to docs content

Address review nits on the pathchange highlight pass:
- Move hljs.configure({ ignoreUnescapedHTML: true }) out of the per-element
  loop since it is global config and only needs to run once.
- Scope the selector to '.docs-content pre code' so the re-highlight pass
  only touches the swapped-in docs body and leaves code blocks elsewhere
  on the page untouched.
2026-06-28 23:27:07 +07:00
Nariman Jelveh 50039571b6 Reorder recommended apps and add vault
Adjust RECOMMENDED_APP_NAMES ordering: place 'editor', 'camera', and 'recorder' immediately after 'builder' and move 'app-center' accordingly. Also add 'vault' to the recommended apps list. This changes the resolved ordering of recommended apps at call time.
2026-06-27 17:55:52 -07:00
Daniel Salazar 1216f20567 fix: package-lock issue (#3316) 2026-06-27 14:33:11 -07:00
Daniel Salazar 0752ea761c feat: remove stych, add prelude client (#3312) 2026-06-27 14:07:52 -07:00
Daniel Salazar 98461088f8 fix: decrease puts for unused stamps (#3310) 2026-06-26 12:33:04 -07:00
Neal Shah d4a3224aa3 force locale en-US (#3311) 2026-06-26 15:31:58 -04:00
Daniel Salazar 197ce6faff fix: LegacyFSController (#3308) 2026-06-25 14:47:29 -07:00
MRB Labs c98e293879 fix: unlink OIDC providers when user updates email address (#3307) 2026-06-25 14:46:41 -07:00
Miika Kuisma ebbe382596 tweak context menu's text labels (#3306) 2026-06-25 09:46:05 -07:00
Neal Shah 01d28eb2c5 Await phone verification event (#3305) 2026-06-24 20:08:30 -04:00
Daniel Salazar 7d1c2e7f0a fix: fsService copy (#3304) 2026-06-24 16:32:34 -07:00
Daniel Salazar 22b499af20 fix: cleanup fs responses (#3303) 2026-06-24 14:35:58 -07:00
Neal Shah 7208f86723 bind btoa and atob in emulated worker globalspace (#3302)
* bind btoa and atob in emulated worker globalspace

* Create working directory for workers
2026-06-24 15:56:26 -04:00
Neal Shah 2974a8b01c store app owner ID even when worker is deployed from root ctx (#3301) 2026-06-24 15:21:48 -04:00
Daniel Salazar 7f05a707bd feat: improve kv store incr/decr costs (#3300) 2026-06-24 11:57:36 -07:00
Miika Kuismaandjelveh 1d056253ef contextmenu design tweak (#3298)
* contextmenu design tweak

* icon gap fix (mobile context menu web component)

* fix submenu item padding

* add dark theme support for puter.ui.contextMenu and puter.ui.setMenubar (works only with puter.env === 'web')

* Add theme test & refactor context menu items

Extract shared mediaMenuItems to remove duplication and simplify context menu calls. Add a light-theme context menu handler and a "Run theme test" button that executes runContextMenuThemeTest(), which programmatically renders menus to verify the theme attribute and .puter-theme-dark class for dark, light, and default cases (mirrors existing Playwright assertions). Update testContextMenu and testContextMenuDark to use the new item factory and clean up rendered menus after checks. UI text is updated to show pass/fail results.

---------

Co-authored-by: jelveh <[email protected]>
2026-06-24 11:30:16 -07:00
Neal Shah a22dc5facb fix whoami user token check (#3299) 2026-06-24 14:28:02 -04:00
Neal Shah aeafd4ac79 txt2img megaupdate (#3294)
* Update OpenAIImageProvider with txt2img with input source

* Grok image provider mega update

* make input_images universal
2026-06-24 10:24:09 -04:00
Daniel Salazar 14861df6b2 fix: bad boot error (#3295) 2026-06-24 00:33:38 -07:00
Daniel Salazar 34a7595a6d fix: bad empty file error handling (#3293) 2026-06-23 16:13:41 -07:00
Daniel Salazar b2108c0216 fix: update email for supprot (#3292) 2026-06-23 15:43:12 -07:00
Philip Ifeanyi 5a5a449210 feat: enhance playground UI with page title and style adjustments (#3289) 2026-06-23 22:45:18 +07:00
197 changed files with 14984 additions and 7942 deletions

No files matched your search

+3 -1
View File
@@ -53,7 +53,7 @@ The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts))
- **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.
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
- **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`).
@@ -64,6 +64,8 @@ Keep comments light. Prefer self-documenting code — clear names, small functio
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
- A non-trivial usage detail would otherwise trip the next reader.
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
+1
View File
@@ -0,0 +1 @@
FOLLOW ./AGENTS.md
+1
View File
@@ -23,6 +23,7 @@
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
"storage_capacity": 104857600,
"disable_user_signup": false,
"strict_email_verification_required": false,
"gui_assets_root": "./src/gui",
"puterjs_root": "./src/puter-js/dist",
+9
View File
@@ -16,6 +16,12 @@
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
// dev-time webpack watcher; `prod` serves pre-built bundles.
"env": "dev",
// Console output format. `json` replaces the global console so every call
// emits one structured JSON line (level, timestamp, msg, and the active
// request's trace id) — one event per call, so a line-oriented log
// collector can't split stack traces across events and level filtering
// works. Unset (the default) leaves console output human-readable.
"log_format": "text",
"version": "0.0.0",
// Stable identity for this server node — used by pager alerts and
// graceful-shutdown coordination.
@@ -75,6 +81,9 @@
"url_signature_secret": "change-me",
"cookie_name": "puter_auth_token",
"min_pass_length": 6,
// When true, anonymous users must log in instead of creating temp or
// permanent accounts.
"disable_user_signup": false,
"allow_system_login": false,
"strict_email_verification_required": false,
"captcha": {
+10 -1
View File
@@ -135,7 +135,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.
- `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/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
@@ -379,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
`difficulty` is one of `easy` / `medium` / `hard`.
### Disable new signups
Force visitors to log in with an existing account instead of creating a
temporary or permanent one.
```json
"disable_user_signup": true
```
### Block disposable email TLDs
Only enforced when `env: "prod"`.
+59 -141
View File
@@ -1,75 +1,66 @@
import type { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runWithContext } from '../src/backend/core/context.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import {
handleAppTelemetryUserCount,
handleAppTelemetryUsers,
} from './appTelemetry.ts';
interface CapturedResponse {
body: unknown;
}
const makeReq = (query: Record<string, unknown> = {}): Request =>
({ query }) as unknown as Request;
const makeRes = () => {
const captured: CapturedResponse = { body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = async (
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
fn: () => Promise<void>,
) => runWithContext({ actor }, fn);
// Importing the module registers the `appTelemetry` driver into the shared
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
import { AppTelemetryDriver } from './appTelemetry.ts';
let server: PuterServer;
let driver: AppTelemetryDriver;
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = <T>(
actor: { user: { uuid: string; id?: number } } | undefined,
fn: () => Promise<T>,
) => runWithContext({ actor }, fn);
beforeAll(async () => {
server = await setupTestServer();
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
.appTelemetry;
// Guard: the driver must have been wired onto the server. If this is
// undefined the extension didn't register, and every test below would
// fail with a confusing "cannot read property of undefined".
expect(driver).toBeInstanceOf(AppTelemetryDriver);
});
afterAll(async () => {
await server.shutdown();
});
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
const seedOwnedApp = async (prefix: string) => {
const owner = await server.stores.user.create({
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `${prefix}_${slug}`,
title: `${prefix} ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
return { owner, app: app! };
};
describe('appTelemetry driver — get_users', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(makeReq({}), res),
).rejects.toMatchObject({
await expect(driver.get_users({})).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('app_uuid'),
});
});
it('throws HttpError(400) for a non-numeric limit', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
res,
),
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('limit'),
@@ -77,22 +68,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
res,
),
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws HttpError(404) when the app cannot be found', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-does-not-exist' }),
res,
),
driver.get_users({ app_uuid: 'app-does-not-exist' }),
).rejects.toMatchObject({
statusCode: 404,
message: 'App not found',
@@ -100,25 +83,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(403) when the caller does not own the app', async () => {
// Seed an owner user + an app belonging to them, then call the
// handler as a *different* user. The real permission service should
// reject since the caller has no `apps-of-user:<owner>:write` perm.
const owner = await server.stores.user.create({
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app_${slug}`,
title: `App ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
// Seed an owner + their app, then call as a *different* user. The
// real permission service should reject: the caller has no
// `apps-of-user:<owner>:write`.
const { app } = await seedOwnedApp('owner');
const intruder = await server.stores.user.create({
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
@@ -126,15 +94,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
email: null,
});
const { res } = makeRes();
await callWithActor(
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
async () => {
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
),
driver.get_users({ app_uuid: app.uid }),
).rejects.toMatchObject({
statusCode: 403,
message: 'Permission denied',
@@ -144,78 +108,32 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('returns an empty list for an owned app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app2_${slug}`,
title: `App2 ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { owner, app } = await seedOwnedApp('owner2');
const { res, captured } = makeRes();
await callWithActor(
const result = await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
async () => {
await handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
);
},
() => driver.get_users({ app_uuid: app.uid }),
);
expect(captured.body).toEqual([]);
expect(result).toEqual([]);
});
});
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
describe('appTelemetry driver — user_count', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(makeReq({}), res),
).rejects.toMatchObject({ statusCode: 400 });
await expect(driver.user_count({})).rejects.toMatchObject({
statusCode: 400,
});
});
it('throws HttpError(404) for an unknown app_uuid', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(
makeReq({ app_uuid: 'app-not-here' }),
res,
),
driver.user_count({ app_uuid: 'app-not-here' }),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns { count: 0 } for an app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `appcount_${slug}`,
title: `AppCount ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { res, captured } = makeRes();
await handleAppTelemetryUserCount(
makeReq({ app_uuid: app!.uid }),
res,
);
expect(captured.body).toEqual({ count: 0 });
it('returns 0 for an app with no authenticated users', async () => {
const { app } = await seedOwnedApp('appcount');
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
});
});
+88 -80
View File
@@ -1,11 +1,16 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import type { Actor } from '@heyputer/backend/src/core/actor';
import { HttpError } from '@heyputer/backend/src/core/http';
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
import { extension } from '@heyputer/backend/src/extensions';
const clients = extension.import('client');
const stores = extension.import('store');
const services = extension.import('service');
// App-telemetry lets an app owner enumerate the users who have
// authenticated into their app. v1 shipped this as a driver on the
// `app-telemetry` interface (methods `get_users` / `user_count`) and
// puter-js's `puter.apps(...).getUsers()` still calls it that way
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
// This is the v2 port of that driver — same interface/method/return shapes
// so existing puter-js callers work unchanged.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@ -41,91 +46,94 @@ const parseIntParam = (
return parsed;
};
export const handleAppTelemetryUsers = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
/**
* Driver exposing the `app-telemetry` interface.
*
* The `/drivers/call` permission gate checks
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
* via the blanket `service` grant (hardcoded-permissions.js +
* `default_implicit_user_app_permissions`). The real authorization — "is the
* caller the app owner?" — is enforced inside `get_users` below, exactly as
* v1 did.
*/
export class AppTelemetryDriver extends PuterDriver {
readonly driverInterface = 'app-telemetry';
readonly driverName = 'app-telemetry';
readonly isDefault = true;
const safeLimit = parseIntParam(req.query.limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(req.query.offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
/** Users who have authenticated into the given app (owner-only). */
async get_users({
app_uuid,
limit,
offset,
}: {
app_uuid?: string;
limit?: unknown;
offset?: unknown;
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const safeLimit = parseIntParam(limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly. v1 got
// this for free because its entity-storage layer eager-joined the
// owner row; v2's AppStore.getByUid returns the raw row with only
// `owner_user_id` populated.
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = (await stores.user.getById(ownerId)) as {
uuid?: string;
} | null;
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const actor = Context.get('actor');
const ownsApp = await services.permission
.check(actor!, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly — the raw
// app row only carries `owner_user_id`. (v1 got the owner for free
// because its entity-storage layer eager-joined the owner row.)
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = await this.stores.user.getById(ownerId);
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const users = await clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
);
const actor = Context.get('actor');
if (!actor) throw new HttpError(401, 'Authentication required');
const ownsApp = await this.services.permission
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
res.json(
(users as Array<{ username: string; uuid: string }>).map((e) => ({
user: e.username,
user_uuid: e.uuid,
})),
);
};
const users = (await this.clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[(app as { id: number }).id, safeLimit, safeOffset],
)) as Array<{ username: string; uuid: string }>;
export const handleAppTelemetryUserCount = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
return users.map((e) => ({ user: e.username, user_uuid: e.uuid }));
}
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
/** Count of users who have authenticated into the given app. */
async user_count({
app_uuid,
}: { app_uuid?: string } = {}): Promise<number> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const [row] = (await clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as Record<string, unknown>).id],
)) as Array<{ n: number }>;
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
res.json({ count: row?.n ?? 0 });
};
const [row] = (await this.clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as { id: number }).id],
)) as Array<{ n: number }>;
extension.get(
'/app-telemetry/users',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUsers,
);
return row?.n ?? 0;
}
}
extension.get(
'/app-telemetry/user-count',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUserCount,
);
extension.registerDriver('appTelemetry', AppTelemetryDriver);
+32
View File
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
expect(list[0].uid).toBe(app!.uid);
expect(list[0].name).toBe(app!.name);
expect(list[0].title).toBe(app!.title);
// index_url is required so the dashboard can derive a hostname title
// for anonymous (app-…) apps.
expect(list[0].index_url).toBe(app!.index_url);
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
true,
);
// An owned app is not external, and the raw owner id must not leak.
expect(list[0].external).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('flags apps with no owner_user_id as external', async () => {
const user = await seedUser();
const slug = Math.random().toString(36).slice(2, 8);
// createFromOrigin bootstraps an app with owner_user_id = null.
const app = await server.stores.app.createFromOrigin(
`app-${slug}`,
`https://external-${slug}.example.com`,
);
await grantInstalled(app!.id as number, user.id as number);
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleInstalledApps(makeReq({}), res),
);
const list = captured.body as Array<Record<string, unknown>>;
expect(list).toHaveLength(1);
expect(list[0].external).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
+14 -4
View File
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
apps.title,
apps.description,
apps.icon,
apps.index_url,
apps.owner_user_id,
MIN(perm.dt) AS installed_at
FROM apps
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
@@ -62,10 +64,18 @@ export const handleInstalledApps = async (
const apiBaseUrl = extension.config.api_base_url as string | undefined;
res.json(
installedApps.map((app) => ({
...app,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
})),
installedApps.map((app) => {
// An app with no owner_user_id (null/empty) isn't owned by a Puter
// user — it's an "external" app. Derive a flag and don't leak the
// raw owner id to the client.
const { owner_user_id, ...rest } = app;
const external = owner_user_id == null || owner_user_id === '';
return {
...rest,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
external,
};
}),
);
};
+47
View File
@@ -107,6 +107,8 @@ describe('whoami extension — handleWhoami', () => {
expect(body.email).toBe(user.email);
expect(body.is_temp).toBe(false);
expect(body.oidc_only).toBe(false);
// is_user_token is present (true) for user actors.
expect(body.is_user_token).toBe(true);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
// taskbar_items is only sent to user actors.
@@ -146,6 +148,8 @@ describe('whoami extension — handleWhoami', () => {
const body = captured.body as Record<string, unknown>;
expect(body.app_name).toBe('app-test-actor');
// is_user_token is stripped for app actors.
expect(body.is_user_token).toBeUndefined();
expect(body.desktop_bg_url).toBeUndefined();
expect(body.desktop_bg_color).toBeUndefined();
expect(body.desktop_bg_fit).toBeUndefined();
@@ -154,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
expect(body.directories).toBeUndefined();
});
it('redacts tmp_password from metadata for user actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
hasDevAccountAccess: true,
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
const metadata = body.metadata as Record<string, unknown>;
expect(metadata.tmp_password).toBeUndefined();
// Other metadata keys still reach the user's own client.
expect(metadata.hasDevAccountAccess).toBe(true);
expect(body.hasDevAccountAccess).toBe(true);
});
it('never sends user metadata to app actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
});
const { res, captured } = makeRes();
await runWithContext(
{
actor: {
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
},
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.metadata).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
});
it('marks the user as oidc_only when password is null', async () => {
const slug = Math.random().toString(36).slice(2, 8);
const oidcUser = await server.stores.user.create({
+7 -1
View File
@@ -65,6 +65,9 @@ export const handleWhoami = async (
}
}
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
if (metadata) delete metadata.tmp_password;
const details: Record<string, unknown> = {
username: user.username,
uuid: user.uuid,
@@ -79,6 +82,7 @@ export const handleWhoami = async (
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
oidc_only: oidcOnly,
taskbar_items: isUser
? await getTaskbarItems(
@@ -97,7 +101,7 @@ export const handleWhoami = async (
human_readable_age: user.timestamp
? timeago.format(new Date(user.timestamp as string))
: null,
metadata: user.metadata,
metadata,
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
@@ -160,6 +164,8 @@ export const handleWhoami = async (
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.is_user_token;
delete details.metadata;
}
if (actor.app) {
+4802 -5043
View File
File diff suppressed because it is too large. Load diff
+9 -4
View File
@@ -46,7 +46,7 @@
"scripts": {
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run setupExtensions && npm run build:ts",
"dev": "npm start",
@@ -78,15 +78,20 @@
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.6",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
"engines": {
"node": ">=24.0.0"
},
"allowScripts": {
"[email protected]": true,
"[email protected]": true,
"@google/genai": false,
"[email protected].1": true
"[email protected].1": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
}
}
@@ -20,6 +20,7 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { createPool, type Pool } from 'mysql2';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { SQLBatcher } from './SQLBatcher.js';
import { splitMysqlStatements } from './splitMysqlStatements.js';
@@ -135,6 +136,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
// The db.* spans measure the logical query, including time queued in
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
// coalesced multi-statement flush, so per-query latency lives here.
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -144,6 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
@@ -153,6 +159,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -170,6 +177,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
@@ -195,6 +205,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
@@ -20,6 +20,7 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { Pool, type PoolConfig, type QueryResult } from 'pg';
import { Span } from '../../util/span.js';
import {
AbstractDatabaseClient,
type BatchEntry,
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return value;
}
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return normalizePostgresRows(result);
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return normalizePostgresRows(result);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
return mapPostgresWriteResult(result);
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(entries: BatchEntry[]): Promise<void> {
if (entries.length === 0) return;
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
@@ -21,6 +21,7 @@ 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 { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
@@ -88,6 +89,11 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[52, ['0057_add_user_reputation.sql']],
[53, ['0058_add_phone_verification.sql']],
[54, ['0059_add_card_verification.sql']],
[55, ['0060_add_card_fingerprint.sql']],
[56, ['0061_add_suspended_at.sql']],
[57, ['0062_blocked-app-origins.sql']],
[58, ['0063_add_suspended_reason.sql']],
[59, ['0064_abuse-moderation-events.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -131,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -151,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
return this.read(query, params);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -167,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
@@ -0,0 +1,46 @@
-- 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/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations); the index add is guarded against
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_suspended_at_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_suspended_at'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_suspended_at_index();
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
@@ -0,0 +1,36 @@
-- 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/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
-- An app whose `index_url` host (or a request origin) matches an entry is
-- denied access to Puter resources: it cannot obtain an app token and
-- already-issued app tokens are rejected on each request. `include_subdomains
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
-- AppOriginBlocklistService.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
`id` INT NOT NULL AUTO_INCREMENT,
`domain` VARCHAR(255) NOT NULL,
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
`reason` TEXT DEFAULT NULL,
`created_by` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,26 @@
-- 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/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations).
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
@@ -0,0 +1,38 @@
-- 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/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
-- gives the denominator. Preserves history the `user` suspension columns lose
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
`id` INT NOT NULL AUTO_INCREMENT,
`action` VARCHAR(32) NOT NULL,
`target_user_id` BIGINT DEFAULT NULL,
`target_username` VARCHAR(255) DEFAULT NULL,
`admin_username` VARCHAR(255) DEFAULT NULL,
`reason` TEXT DEFAULT NULL,
`source` VARCHAR(64) DEFAULT NULL,
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
KEY `idx_abuse_moderation_events_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,25 @@
-- 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/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
@@ -0,0 +1,36 @@
-- 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/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
-- `index_url` host (or a request origin) matches an entry is denied access to
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS blocked_app_origins (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
include_subdomains SMALLINT NOT NULL DEFAULT 0,
reason TEXT DEFAULT NULL,
created_by VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
ON blocked_app_origins (domain);
@@ -0,0 +1,24 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
@@ -0,0 +1,40 @@
-- 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/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
-- signals; `suspend` gives the denominator. Preserves history the `user`
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
id BIGSERIAL PRIMARY KEY,
action VARCHAR(32) NOT NULL,
target_user_id BIGINT DEFAULT NULL,
target_username VARCHAR(255) DEFAULT NULL,
admin_username VARCHAR(255) DEFAULT NULL,
reason TEXT DEFAULT NULL,
source VARCHAR(64) DEFAULT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON abuse_moderation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON abuse_moderation_events (action);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- When the account was suspended, as unix seconds; NULL while not suspended.
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
@@ -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/>.
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
-- a request origin) matches an entry is denied access to Puter resources:
-- it cannot obtain an app token and already-issued app tokens are rejected
-- on each request. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"domain" TEXT NOT NULL,
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
"reason" TEXT DEFAULT NULL,
"created_by" TEXT DEFAULT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
ON `blocked_app_origins` (`domain`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Why an account was suspended, NULL while not suspended. Companion to the
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
-- application layer to a fixed set of reasons (see extensions/admin
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
-- needs no migration.
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
@@ -0,0 +1,43 @@
-- 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/>.
-- Append-only log of admin moderation actions, used to measure the abuse
-- system's false-positive rate: `unsuspend` (admin unblock) and
-- `admin_create_user` (an account minted by an admin, typically to recover a
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
-- columns on `user` — which are cleared on unsuspend and so keep no history —
-- this table preserves every event. `created_at` is unix seconds (matching
-- `user.suspended_at`), stamped by the recorder in
-- extensions/admin/moderation_events.js. Written best-effort at the admin
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
"target_user_id" INTEGER DEFAULT NULL,
"target_username" TEXT DEFAULT NULL,
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
"reason" TEXT DEFAULT NULL,
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
"created_at" INTEGER NOT NULL -- unix seconds
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON `abuse_moderation_events` (`created_at`);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON `abuse_moderation_events` (`action`);
+12
View File
@@ -43,6 +43,7 @@ import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import { Span } from '../../util/span.js';
import { PuterClient } from '../types';
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
await this.#localInitPromise;
}
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
async get<T extends Record<string, unknown>>(
table: string,
key: T,
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
const command = new PutCommand({
TableName: table,
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchGet', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchGet(
params: { table: string; items: Record<string, unknown> }[],
consistentRead = false,
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchPut', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
const consumedCapacityByTable = new Map<string, number>();
if (params.length === 0) {
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
};
}
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
async del<T extends Record<string, unknown>>(table: string, key: T) {
const command = new DeleteCommand({
TableName: table,
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
async query<T extends Record<string, unknown>>(
table: string,
keys: T,
@@ -383,6 +394,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
async update<T extends Record<string, unknown>>(
table: string,
key: T,
+30 -12
View File
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
import { PuterClient } from '../types';
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
// -- Types ------------------------------------------------------------
/** Attachment shape passed through to the underlying transport. */
export interface EmailAttachment {
filename: string;
content: Buffer | string;
contentType?: string;
encoding?: string;
}
// nodemailer doesn't ship TS types, so declare the subset we use.
interface NodemailerTransport {
sendMail: (options: SendMailOptions) => Promise<unknown>;
close?: () => void;
/** Subset of the transport's send result callers may care about. */
export interface SentMessageInfo {
messageId?: string;
accepted?: string[];
rejected?: string[];
[key: string]: unknown;
}
export interface SendMailOptions {
@@ -41,6 +49,7 @@ export interface SendMailOptions {
html?: string;
text?: string;
replyTo?: string;
attachments?: EmailAttachment[];
}
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
* - Policy + extensible validation (via `validate`)
*/
export class EmailClient extends PuterClient {
private transport: NodemailerTransport | null = null;
private transport: ReturnType<typeof nodemailer.createTransport> | null =
null;
private compiledTemplates: Partial<
Record<EmailTemplateName, CompiledTemplate>
> = {};
@@ -123,8 +133,7 @@ export class EmailClient extends PuterClient {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.transport = nodemailer.createTransport(emailConf as any);
this.transport = nodemailer.createTransport(emailConf);
console.log('[email] transport configured');
}
@@ -159,21 +168,30 @@ export class EmailClient extends PuterClient {
/**
* Raw send — bypasses the template system. Useful for one-off
* admin emails that don't warrant a named template.
*
* Returns the transport's send result, or `null` when no transport
* is configured (the send is a no-op in that case — callers that
* must not silently drop mail should check `isConfigured` first).
*/
async sendRaw(options: SendMailOptions): Promise<void> {
async sendRaw(options: SendMailOptions) {
if (!this.transport) {
console.warn(
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
options,
);
return;
return null;
}
await this.transport.sendMail({
from: options.from ?? this.defaultFrom(),
return await this.transport.sendMail({
...options,
from: options.from ?? this.defaultFrom(),
});
}
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
get isConfigured(): boolean {
return this.transport !== null;
}
// -- Public API: clean / validate ---------------------------------
/**
+40 -18
View File
@@ -18,6 +18,7 @@
*/
import { extensionStore } from '../../extensions';
import { withSpan } from '../../util/span.js';
import { PuterClient } from '../types';
import {
EventListener,
@@ -99,26 +100,34 @@ export class EventClient extends PuterClient {
data: EventMap[T],
meta: EventMetadata,
) {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
const matchKey = (
i === parts.length - 1
? key
: `${parts.slice(0, i + 1).join('.')}.*`
) as ListenKey;
const extensionListeners = extensionStore.events[matchKey];
const listeners = (this.#eventListeners[matchKey] || []).concat(
extensionListeners || [],
);
if (!listeners) continue;
for (const listener of listeners) {
try {
await listener(key, data, meta);
} catch (e) {
console.error('Error in event listener for event', key, e);
// Spanned because callers block on listeners — this is where time
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
const parts = key.split('.');
for (let i = 0; i < parts.length; i++) {
const matchKey = (
i === parts.length - 1
? key
: `${parts.slice(0, i + 1).join('.')}.*`
) as ListenKey;
const extensionListeners = extensionStore.events[matchKey];
const listeners = (this.#eventListeners[matchKey] || []).concat(
extensionListeners || [],
);
if (!listeners) continue;
for (const listener of listeners) {
try {
await listener(key, data, meta);
} catch (e) {
console.error(
'Error in event listener for event',
key,
e,
);
}
}
}
}
});
}
/**
@@ -146,6 +155,19 @@ export class EventClient extends PuterClient {
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
listeners.push(callback as EventListener);
}
off<P extends ListenKey>(
key: P,
callback: (
key: MatchingEvents<P>,
data: EventMap[MatchingEvents<P>],
meta: EventMetadata,
) => Promise<void> | void,
) {
const listeners = this.#eventListeners[key];
if (!listeners) return;
const idx = listeners.indexOf(callback as EventListener);
if (idx !== -1) listeners.splice(idx, 1);
}
async #emitEvent<T extends keyof EventMap>(
listener: EventListener,
key: T,
+20 -2
View File
@@ -120,6 +120,21 @@ export type EventMap = {
actor: unknown;
result: { allowed: boolean; reason?: string; error?: string };
};
// Emitted after an app token is granted (get-user-app-token). The app-side
// analogue of `site.htmlServed` — the external-app abuse scanner
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
// external `index_url`s that Puter never serves itself.
'puter.app.authenticated': {
app_uid?: string;
user_id?: number | null;
app?: {
id?: number;
uid?: string;
index_url?: string | null;
owner_user_id?: number | null;
name?: string | null;
};
};
// ---- Auth / signup ----
'puter.signup.validate': {
@@ -137,9 +152,8 @@ export type EventMap = {
* the request for log / support correlation.
*/
trail_id?: string;
/** Device signals forwarded verbatim from the signup request body. */
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: string | null;
dfp_telemetry_id?: string | null;
/** Set by the abuse harness — require SMS phone verification post-signup. */
requires_phone_verification?: boolean;
/** Set by the abuse harness — require card verification post-signup. */
@@ -237,6 +251,7 @@ export type EventMap = {
fingerprint: string | null;
funding: string | null;
country: string | null;
customer_id: string | null;
[key: string]: unknown;
};
'user.card-verified': {
@@ -245,6 +260,7 @@ export type EventMap = {
fingerprint: string | null;
funding: string | null;
country: string | null;
customer_id: string | null;
};
'user.username-changed': {
user_id: number;
@@ -376,6 +392,8 @@ export type EventMap = {
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
// wildcard + veto semantics as the driver lifecycle above.
[K in `route.${string}`]: RouteLifecycleEvent;
} & {
[K in `pubsub.login.${string}`]: { authtoken: string };
};
/**
@@ -79,12 +79,28 @@ describe('PreludeClient', () => {
expect(init.headers.Authorization).toBe('Bearer sk_test');
expect(JSON.parse(init.body)).toEqual({
target: { type: 'phone_number', value: '+14155550123' },
// Defaults to RCS (cheaper); Prelude falls back to SMS.
options: { code_size: 6, preferred_channel: 'rcs' },
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
// hardcoded to en-US so the message text is always English.
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
signals: { ip: '203.0.113.7' },
});
});
it('returns the delivery channel sequence Prelude reports', async () => {
fetchMock.mockResolvedValue(
okJson({
id: 'vrf_1',
status: 'success',
channels: ['whatsapp', 'sms'],
}),
);
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123');
expect(res.channels).toEqual(['whatsapp', 'sms']);
});
it('forwards device_id and user_agent signals when supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
@@ -114,6 +130,32 @@ describe('PreludeClient', () => {
);
});
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
expect(body.signals).toEqual({ ip: '203.0.113.7' });
expect(body.signals).not.toHaveProperty('dispatch_id');
});
it('omits dispatch_id when not supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
'dispatch_id',
);
});
it('includes a configured template_id + sender_id + preferred channel', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = new PreludeClient({
@@ -131,6 +173,7 @@ describe('PreludeClient', () => {
expect(JSON.parse(init.body).options).toEqual({
code_size: 6,
preferred_channel: 'sms', // config override beats the rcs default
locale: 'en-US',
template_id: 'tmpl_puter',
sender_id: 'Puter',
});
+16 -12
View File
@@ -42,6 +42,12 @@ export type PreludeChannel =
| 'viber'
| 'zalo'
| 'telegram';
/**
* Channels Prelude reports in a verification's `channels` array — the ordered
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
* and 'voice' can appear as delivery methods but can't be preferred.
*/
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
/**
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
* this — or that have no SMS channel — are not offered phone verification. The
@@ -117,25 +123,19 @@ export class PreludeClient extends PuterClient {
return price.sms <= this.maxSmsCostEur;
}
/**
* Create (or retry) a verification: Prelude sends an OTP to `target`.
* @param target E.164 phone number, e.g. "+14155550123".
* @param signals Optional anti-fraud signals. The more we forward, the
* better Prelude's fraud model scores the attempt (per their docs IP +
* device + platform are the highest-value). We pass what the backend
* already has: the request `ip`, the client `device_id` (ThumbmarkJS
* hash), and the `user_agent` (Prelude infers platform/model/OS from it).
* Richer client-collected signals require Prelude's frontend SDK, which
* produces a `dispatch_id` the backend would forward here.
*/
async createVerification(
target: string,
signals: {
ip?: string;
device_id?: string;
user_agent?: string;
dispatch_id?: string;
} = {},
): Promise<{ id?: string; status: PreludeCreateStatus }> {
): Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}> {
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
// code_size Prelude uses the dashboard default (4). preferred_channel
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
@@ -144,6 +144,7 @@ export class PreludeClient extends PuterClient {
preferred_channel:
this.config.prelude?.preferredChannel ??
DEFAULT_PREFERRED_CHANNEL,
locale: 'en-US',
};
// Branding lives in the Prelude dashboard (the message text is a
// template); these just select a Puter-branded template / sender when
@@ -163,9 +164,12 @@ export class PreludeClient extends PuterClient {
if (signals.device_id) sig.device_id = signals.device_id;
if (signals.user_agent) sig.user_agent = signals.user_agent;
if (Object.keys(sig).length > 0) body.signals = sig;
// dispatch_id is a top-level field, not a member of `signals`.
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
return this.#post('/verification', body) as Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}>;
}
@@ -586,6 +586,24 @@ describe('AuthController.handleSignup', () => {
);
});
it('rejects brand-new temp signups when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
await expect(
controller.handleSignup(makeReq({ is_temp: true }), makeRes()),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
it('emits puter.signup.success on successful signup', async () => {
const baseline = heardSignupSuccess.length;
const username = `s_${uniq()}`;
@@ -607,9 +625,93 @@ describe('AuthController.handleSignup', () => {
),
).toBe(true);
});
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
const targetEmail = `disabled_claim_${uniq()}@test.local`;
const placeholder = await server.stores.user.create({
username: `placeholder_${uniq()}`,
uuid: uuidv4(),
password: null,
email: targetEmail,
clean_email: targetEmail,
email_confirmed: 0,
} as never);
const res = makeRes();
await controller.handleSignup(
makeReq({
username: `claim_${uniq()}`,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.username).not.toBe(placeholder.username);
} finally {
authConfig.disable_user_signup = prev;
}
});
it('does not reveal existing usernames or emails when registration is disabled', async () => {
const username = `taken_${uniq()}`;
const email = `${username}@test.local`;
await controller.handleSignup(
makeReq({ username, email, password: 'correct-horse-battery' }),
makeRes(),
);
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
// Taken username → the generic 403, not the duplicate error.
await expect(
controller.handleSignup(
makeReq({
username,
email: `fresh_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
// Taken (non-claimable) email → same generic 403.
await expect(
controller.handleSignup(
makeReq({
username: `fresh_${uniq()}`,
email,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
});
// -- Signup device signals (fingerprint + dfp_telemetry_id) --
// -- Signup device signal (fingerprint) --
describe('AuthController.handleSignup device signals', () => {
const uniq = () => Math.random().toString(36).slice(2, 10);
@@ -631,11 +733,10 @@ describe('AuthController.handleSignup device signals', () => {
(evt) => (evt as { username?: string }).username === username,
);
it('forwards fingerprint and dfp_telemetry_id verbatim to validate and success events', async () => {
it('forwards fingerprint verbatim to validate and success events', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const fingerprint = 'Fp_abc.123-XYZ';
const dfpTelemetryId = 'tel_id-456';
const seen = await captureValidateEvents(async () => {
const res = makeRes();
@@ -645,7 +746,6 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint,
dfp_telemetry_id: dfpTelemetryId,
}),
res,
);
@@ -654,7 +754,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBe(fingerprint);
expect(seen[0].dfp_telemetry_id).toBe(dfpTelemetryId);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
@@ -662,7 +761,7 @@ describe('AuthController.handleSignup device signals', () => {
expect(successes[0].is_temp).toBe(false);
});
it('accepts boundary-length values (128-char fingerprint, 64-char dfp_telemetry_id)', async () => {
it('accepts a boundary-length 128-char fingerprint', async () => {
const username = `fp_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
@@ -671,14 +770,13 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(128),
dfp_telemetry_id: 'd'.repeat(64),
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('defaults both fields to null on the validate event when absent', async () => {
it('defaults the fingerprint to null on the validate event when absent', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
@@ -699,7 +797,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
@@ -707,7 +804,7 @@ describe('AuthController.handleSignup device signals', () => {
expect(successes[0].is_temp).toBe(false);
});
it('treats empty-string fingerprint and dfp_telemetry_id as absent', async () => {
it('treats an empty-string fingerprint as absent', async () => {
const username = `fp_${uniq()}`;
const seen = await captureValidateEvents(async () => {
@@ -718,7 +815,6 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: '',
dfp_telemetry_id: '',
}),
res,
);
@@ -728,7 +824,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
});
it('rejects a non-string fingerprint with 400 and fires no success event', async () => {
@@ -770,37 +865,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a dfp_telemetry_id longer than 64 characters with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: 'd'.repeat(65),
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a non-string dfp_telemetry_id with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `fp_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: { nested: true },
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
@@ -1902,6 +1966,104 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
);
});
it('attaches a support error_id to a Prelude block', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({ status: 'blocked' })),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { error_id: expect.any(String) },
});
},
);
});
it('attaches a support error_id when the Prelude request throws', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 502,
fields: { error_id: expect.any(String) },
});
},
);
});
it('persists the send failure to KV under the error_id with a 7-day expiry', async () => {
const { user, actor } = await makeUserAndActor();
const kvSet = vi.spyOn(server.stores.kv, 'set');
try {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
let thrown: HttpError | undefined;
try {
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
);
} catch (e) {
thrown = e as HttpError;
}
const errorId = thrown!.fields!.error_id as string;
const { res: record } = await server.stores.kv.get({
key: `sms-send-error:${errorId}`,
});
expect(record).toMatchObject({
reason: 'prelude_request_failed',
status: 502,
user_id: user.id,
user_uid: user.uuid,
detail: 'network down',
t: expect.any(Number),
});
const errorSet = kvSet.mock.calls.find(([arg]) =>
(arg as { key?: string }).key?.startsWith(
'sms-send-error:',
),
);
const { expireAt } = errorSet![0] as {
expireAt: number;
};
const nowSec = Math.floor(Date.now() / 1000);
expect(expireAt).toBeGreaterThan(
nowSec + 7 * 24 * 60 * 60 - 60,
);
expect(expireAt).toBeLessThanOrEqual(
nowSec + 7 * 24 * 60 * 60,
);
},
);
} finally {
kvSet.mockRestore();
}
});
it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
@@ -1941,6 +2103,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
user_agent: undefined,
});
});
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'success',
channels: ['whatsapp', 'sms'],
})),
}),
async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toMatchObject({ channel: 'whatsapp' });
},
);
});
it('omits `channel` when Prelude reports no delivery sequence', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).not.toHaveProperty('channel');
});
});
});
describe('AuthController phone verification — staging & reuse', () => {
@@ -2240,6 +2434,47 @@ describe('AuthController.handleConfirmPhone', () => {
);
});
it('awaits the user.phone-verified listeners (emitAndWait, not fire-and-forget)', async () => {
// The carrier-based card-verification waiver (abuse extension) listens
// on user.phone-verified and must clear the card gate BEFORE confirm
// responds — so the event has to be awaited, not fire-and-forget.
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {});
const ctrl = controller as { clients: { event: unknown } };
const realEvent = ctrl.clients.event;
ctrl.clients.event = { emit, emitAndWait };
try {
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => ({
status: 'success',
})),
}),
async () => {
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
);
},
);
} finally {
ctrl.clients.event = realEvent;
}
expect(emitAndWait).toHaveBeenCalledWith(
'user.phone-verified',
expect.objectContaining({ phone: '+14155550123' }),
expect.anything(),
);
const fireAndForget = emit.mock.calls.filter(
(c) => c[0] === 'user.phone-verified',
);
expect(fireAndForget).toHaveLength(0);
});
it('throws 502 when the upstream check fails', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
@@ -2489,6 +2724,312 @@ describe('AuthController.handleCardVerificationConfirm', () => {
});
});
describe('AuthController SMS → card fallback', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
const withFallbackConfig = async (
value: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.phone_verification_card_fallback;
cfg.phone_verification_card_fallback = value;
try {
await fn();
} finally {
cfg.phone_verification_card_fallback = prev;
}
};
// Drive the attempt counter directly so the threshold is deterministic
// (the handler keys it the same way: `phone-verify-attempts:<id>`).
const seedAttempts = (userId: number, attempts: number) =>
server.stores.kv.incr({
key: `phone-verify-attempts:${userId}`,
pathAndAmountMap: { attempts },
});
// Stamp the eligibility flag the card endpoints check, the same way a
// threshold-crossing send does (`card-fallback-open:<id>`).
const openFallback = (userId: number) =>
server.stores.kv.set({
key: `card-fallback-open:${userId}`,
value: true,
});
it('offers the fallback on send once the attempt threshold is reached', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No after_attempts → exercises the default threshold of 2.
await withFallbackConfig({ enabled: true }, async () => {
await withPrelude(stubPrelude(), async () => {
const first = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
first,
);
// First attempt is below the threshold — no offer yet.
expect(first.body).toEqual({});
const second = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
second,
);
expect(second.body).toEqual({
card_fallback_available: true,
});
});
});
});
it('never offers the fallback on send when disabled', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: false, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
},
);
});
it('flags the fallback on a Prelude block once eligible', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'blocked',
})),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { card_fallback_available: true },
});
},
);
},
);
});
it('lets card setup proceed past the phone gate once eligible', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('still 409s card setup when no send has opened the fallback', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
// Counter above the threshold but no flag: eligibility is the flag a
// threshold-crossing send stamps, never the raw counter.
await seedAttempts(user.id, 5);
await withFallbackConfig(
{ enabled: true, after_attempts: 3 },
async () => {
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
},
);
});
it('send crossing the threshold opens card setup end-to-end', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const sendRes = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
sendRes,
);
expect(sendRes.body).toEqual({
card_fallback_available: true,
});
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('clamps after_attempts to the send route rate limit', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// 9 prior attempts + this send = 10, the route limit. A threshold of
// 50 could never be crossed, so it clamps down and the offer opens.
await seedAttempts(user.id, 9);
await withFallbackConfig(
{ enabled: true, after_attempts: 50 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
},
);
});
it('clears BOTH gates when the fallback card verifies', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({
card_verified: true,
phone_verified: true,
});
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
expect(after!.requires_phone_verification).toBe(false);
});
it('clears the phone gate via card even when card was not required', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({ phone_verified: true });
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
});
});
describe('AuthController.handleConfirmEmail', () => {
it('throws 400 when code is missing', async () => {
const { actor } = await makeUserAndActor();
+446 -54
View File
@@ -20,9 +20,10 @@
import bcrypt from 'bcrypt';
import type { Request, RequestHandler, Response } from 'express';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4, validate as validateUuid } from 'uuid';
import validator from 'validator';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import type { HttpErrorOptions } from '../../core/http/HttpError.js';
import { HttpError } from '../../core/http/HttpError.js';
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
@@ -47,8 +48,8 @@ import {
} from '../../services/auth/OTPUtil.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { parsePhone } from '../../util/phone.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
generateDefaultFsentries,
@@ -59,7 +60,21 @@ import { PuterController } from '../types.js';
const USERNAME_REGEX = /^\w{1,}$/;
const USERNAME_MAX_LENGTH = 45;
const FINGERPRINT_MAX_LENGTH = 128;
const DFP_TELEMETRY_ID_MAX_LENGTH = 64;
const DISPATCH_ID_MAX_LENGTH = 128;
// Default SMS send attempts before the card fallback opens.
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
// /send-confirm-phone route rate limit. Also caps the fallback's
// `after_attempts`: requests past the route limit are rejected in middleware
// and never reach the attempt counter, so a higher threshold could never be
// crossed.
const SEND_PHONE_RATE_LIMIT = 10;
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
// Once the threshold is crossed the fallback stays open this long, so the
// user can finish the card flow without racing the attempt counter's expiry.
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
// How long a failed-SMS-send record stays readable by its error_id — long
// enough to cover the typical support round-trip.
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -98,6 +113,67 @@ const RESERVED_USERNAMES = new Set([
*/
@Controller('')
export class AuthController extends PuterController {
@Post('/login/wait', {
subdomain: ['api'],
rateLimit: [
// A client will make a request to this every 10 seconds while waiting for the login to complete, so we allow a higher limit than the main /login endpoint.
{ scope: 'login-wait', limit: 100, window: 15 * 60_000, key: 'ip' },
],
})
async loginWait(req: Request, res: Response) {
const { session } = req.body;
// validate uuid to prevent ultra long key or listening on pubsub.login.*
if (!session || !validateUuid(session)) {
throw new HttpError(400, 'session is required.', {
legacyCode: 'bad_request',
});
}
const { resolve, promise } = Promise.withResolvers<void>();
let token: string | null = null;
const listener = (_key: string, value: { authtoken: string }) => {
token = value.authtoken;
resolve();
};
this.clients.event.on(`pubsub.login.${session}`, listener);
const timeout = new Promise<void>((resolve) =>
setTimeout(resolve, 10000),
);
await Promise.race([promise, timeout]);
this.clients.event.off(`pubsub.login.${session}`, listener);
if (!token) {
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
}
res.json({
auth_token: token,
});
}
@Post('/login/set', {
subdomain: ['api'],
})
async loginSet(req: Request, res: Response) {
const { session, auth_token } = req.body;
if (!session || !auth_token || !validateUuid(session)) {
throw new HttpError(400, 'session and auth_token are required.', {
legacyCode: 'bad_request',
});
}
this.clients.event.emit(
`pubsub.login.${session}`,
{
authtoken: auth_token,
},
{},
);
res.json({ success: true });
}
// -- Login -------------------------------------------------------
@Post('/login', {
@@ -378,11 +454,10 @@ export class AuthController extends PuterController {
return;
}
// Optional device signals (browser fingerprint hash, DFP telemetry
// id). Core only enforces shape and forwards the values verbatim —
// signup-abuse policy built on them lives in extensions. Checked
// before the reauth short-circuit so a malformed value is rejected
// on every /signup path.
// Optional device signal (browser fingerprint hash). Core only enforces
// shape and forwards the value verbatim — signup-abuse policy built on it
// lives in extensions. Checked before the reauth short-circuit so a
// malformed value is rejected on every /signup path.
if (body.fingerprint !== undefined && body.fingerprint !== null) {
if (typeof body.fingerprint !== 'string')
throw new HttpError(400, 'fingerprint must be a string.', {
@@ -395,25 +470,9 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
if (
body.dfp_telemetry_id !== undefined &&
body.dfp_telemetry_id !== null
) {
if (typeof body.dfp_telemetry_id !== 'string')
throw new HttpError(400, 'dfp_telemetry_id must be a string.', {
legacyCode: 'bad_request',
});
if (body.dfp_telemetry_id.length > DFP_TELEMETRY_ID_MAX_LENGTH)
throw new HttpError(
400,
`dfp_telemetry_id cannot be longer than ${DFP_TELEMETRY_ID_MAX_LENGTH} characters.`,
{ legacyCode: 'bad_request' },
);
}
// Empty strings are treated as absent — a signal that wasn't
// collected, not a malformed request.
const fingerprint: string | null = body.fingerprint || null;
const dfp_telemetry_id: string | null = body.dfp_telemetry_id || null;
// Temp-user reauth short-circuit: when an existing temp user is
// forced through the reauth flow, the GUI re-submits /signup with
@@ -527,6 +586,31 @@ export class AuthController extends PuterController {
}
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
// allowed, so permanent signups look the email up first.
if (this.config.disable_user_signup) {
let claimable = false;
if (!is_temp) {
const existing =
(await this.stores.user.getByEmail(body.email)) ??
(await this.stores.user.getByCleanEmail(
cleanEmail(body.email),
));
claimable = Boolean(
existing &&
!existing.email_confirmed &&
existing.password === null,
);
}
if (!claimable) {
throw new HttpError(403, 'User registration is disabled.', {
legacyCode: 'signup_disabled',
});
}
}
// Duplicate username check
if (await this.stores.user.getByUsername(body.username)) {
throw new HttpError(
@@ -605,7 +689,6 @@ export class AuthController extends PuterController {
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
fingerprint,
dfp_telemetry_id,
// Populated by the abuse extension's v2 harness; persisted to the
// user row below so the signup-time reputation is referable later.
reputation: null as number | null,
@@ -746,7 +829,6 @@ export class AuthController extends PuterController {
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
fingerprint,
dfp_telemetry_id,
},
signup_ip: clientIp,
signup_ip_forwarded: proxyIpChain,
@@ -1037,14 +1119,176 @@ export class AuthController extends PuterController {
// -- Phone verification (SMS via Prelude) ------------------------
/**
* Build the error thrown when a verification SMS can't be sent (a delivery
* failure, or a refused/blocked send). Mints a short `error_id`, writes a
* single greppable line tying that id to the real reason (so support can
* look it up in CloudWatch with the id the user quotes), stores the same
* record in KV under `sms-send-error:<error_id>` for a week (the admin
* abuse page looks it up there without needing log access), and returns
* the `HttpError` with the id attached as `error_id` for the GUI to
* surface. The phone number is deliberately omitted from the log line and
* the KV record (PII); the user + country are enough to correlate.
*/
private async smsSendError(
statusCode: number,
clientMessage: string,
reason: string,
ctx: {
userId?: number;
userUid?: string;
country?: string;
detail?: unknown;
},
options: HttpErrorOptions = {},
): Promise<HttpError> {
const errorId = uuidv4();
const detail =
ctx.detail instanceof Error ? ctx.detail.message : ctx.detail;
console.warn(
`[send-confirm-phone] send_failed error_id=${errorId} ` +
`reason=${reason} status=${statusCode} ` +
`user_id=${ctx.userId ?? ''} user_uid=${ctx.userUid ?? ''} ` +
`country=${ctx.country ?? ''}` +
(detail ? ` detail=${JSON.stringify(String(detail))}` : ''),
);
// Best-effort: the record backs a support lookup, so a KV failure
// must never mask the error actually being reported.
try {
const now = Math.floor(Date.now() / 1000);
await this.stores.kv.set({
key: `sms-send-error:${errorId}`,
value: {
reason,
status: statusCode,
user_id: ctx.userId ?? null,
user_uid: ctx.userUid ?? null,
country: ctx.country ?? null,
detail: detail != null ? String(detail) : null,
t: now,
},
expireAt: now + SMS_SEND_ERROR_TTL_SECONDS,
});
} catch (e) {
console.warn('[send-confirm-phone] error-record store failed:', e);
}
return new HttpError(statusCode, clientMessage, {
...options,
fields: { ...options.fields, error_id: errorId },
});
}
// -- SMS-to-card fallback -----------------------------------------
//
// Once a user has made enough SMS send attempts in the rate-limit window
// without getting through, they can verify a card instead to clear the
// phone gate. Off unless config enables it.
//
// Two KV keys: a short-lived counter tied to the send rate-limit window
// triggers the fallback, and a longer-lived "open" flag holds eligibility
// once the threshold is crossed. The card endpoints check only the flag —
// deriving eligibility from the raw counter would let it expire while the
// user is mid-way through the card flow. Every KV failure fails closed
// (fallback unavailable), never open.
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
const cfg = this.config.phone_verification_card_fallback;
const afterAttempts = Math.min(
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
? cfg.after_attempts
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
SEND_PHONE_RATE_LIMIT,
);
return { enabled: Boolean(cfg?.enabled), afterAttempts };
}
private phoneAttemptsKey(userId: number): string {
return `phone-verify-attempts:${userId}`;
}
private cardFallbackFlagKey(userId: number): string {
return `card-fallback-open:${userId}`;
}
// TTL ties the counter to the send rate-limit window, so it resets with it.
private async bumpPhoneAttempts(userId: number): Promise<number> {
try {
const { res } = await this.stores.kv.incr({
key: this.phoneAttemptsKey(userId),
pathAndAmountMap: { attempts: 1 },
expireAt:
Math.floor(Date.now() / 1000) +
SEND_PHONE_RATE_WINDOW_MS / 1000,
});
const count = (res as { attempts?: number } | null)?.attempts;
return typeof count === 'number' ? count : 0;
} catch (e) {
console.warn('[send-confirm-phone] attempt-count bump failed:', e);
return 0;
}
}
/**
* Count a send attempt and, once the threshold is crossed, stamp the
* eligibility flag the card endpoints check. Returns whether the fallback
* is open so send responses (success or 429) can advertise it.
*/
private async recordPhoneAttemptForFallback(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const attempts = await this.bumpPhoneAttempts(user.id);
const { enabled, afterAttempts } = this.cardFallbackConfig();
const open =
enabled &&
Boolean(user.requires_phone_verification) &&
attempts >= afterAttempts;
if (open) {
try {
// Plain set, so each eligible attempt refreshes the window.
await this.stores.kv.set({
key: this.cardFallbackFlagKey(user.id),
value: true,
expireAt:
Math.floor(Date.now() / 1000) +
CARD_FALLBACK_OPEN_TTL_SECONDS,
});
} catch (e) {
console.warn(
'[send-confirm-phone] fallback flag stamp failed:',
e,
);
return false;
}
}
return open;
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const { enabled } = this.cardFallbackConfig();
if (!enabled || !user.requires_phone_verification) return false;
try {
const { res } = await this.stores.kv.get({
key: this.cardFallbackFlagKey(user.id),
});
return res === true;
} catch (e) {
console.warn('[card-verification] fallback flag read failed:', e);
return false;
}
}
@Post('/send-confirm-phone', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
rateLimit: {
scope: 'send-confirm-phone',
limit: 10,
window: 60 * 60_000,
limit: SEND_PHONE_RATE_LIMIT,
window: SEND_PHONE_RATE_WINDOW_MS,
key: 'user',
},
})
@@ -1061,9 +1305,13 @@ export class AuthController extends PuterController {
legacyCode: 'account_suspended',
});
if (!this.clients.prelude?.isConfigured())
throw new HttpError(503, 'Phone verification is unavailable.', {
legacyCode: 'service_unavailable' as never,
});
throw await this.smsSendError(
503,
'Phone verification is unavailable.',
'prelude_not_configured',
{ userId: user.id, userUid: user.uuid },
{ legacyCode: 'service_unavailable' as never },
);
// Parse to E.164 (Prelude's required form + the stored form) and the
// country, so we can apply the per-country cost cap.
@@ -1076,16 +1324,42 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
// Optional Prelude dispatch id (browser signals gathered by the JS
// Signals SDK on the number-entry page). Shape-checked and forwarded
// verbatim to Prelude; an empty / oversized / non-string value is just
// dropped so a bad client signal never blocks a real verification.
const rawDispatchId = req.body?.dispatch_id;
const dispatchId =
typeof rawDispatchId === 'string' &&
rawDispatchId.length > 0 &&
rawDispatchId.length <= DISPATCH_ID_MAX_LENGTH
? rawDispatchId
: undefined;
// Cost cap: skip countries with no SMS channel or rates above the cap
// (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS
// rates in low-revenue, high-fraud geographies.
if (!this.clients.prelude.isCountrySupported(parsed.country))
throw new HttpError(
throw await this.smsSendError(
400,
'Phone verification is not available for this country.',
'country_not_supported',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{ legacyCode: 'phone_country_not_supported' as never },
);
// Counted before the abuse / Prelude checks so a blocked attempt still
// counts toward the fallback threshold.
const fallbackAvailable =
await this.recordPhoneAttemptForFallback(user);
const fallbackFields = fallbackAvailable
? { card_fallback_available: true }
: {};
// Abuse caps live ENTIRELY in a listening abuse extension, consulted
// via `puter.phone-verification.check`. The backend ships no thresholds
// or detection of its own (so none of it is readable in the open-source
@@ -1117,14 +1391,23 @@ export class AuthController extends PuterController {
// its meaning lives in the extension (which sets it) and the GUI (which
// displays it), so no abuse semantics leak into the OSS repo.
if (abuseCheck.allowed === false)
throw new HttpError(
throw await this.smsSendError(
429,
'Phone verification is unavailable for this number right now.',
`not_allowed:${abuseCheck.reason ?? 'unspecified'}`,
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{
legacyCode: 'phone_verification_unavailable' as never,
fields: abuseCheck.reason
? { reason: abuseCheck.reason }
: {},
fields: {
...fallbackFields,
...(abuseCheck.reason
? { reason: abuseCheck.reason }
: {}),
},
},
);
@@ -1142,10 +1425,18 @@ export class AuthController extends PuterController {
expireAt: Math.floor(Date.now() / 1000) + 60 * 60,
});
} catch (e) {
console.warn('[send-confirm-phone] pending-store failed:', e);
throw new HttpError(503, 'Could not start phone verification.', {
legacyCode: 'service_unavailable' as never,
});
throw await this.smsSendError(
503,
'Could not start phone verification.',
'pending_store_failed',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
detail: e,
},
{ legacyCode: 'service_unavailable' as never },
);
}
const ip = req.ip || req.socket?.remoteAddress || undefined;
@@ -1153,6 +1444,10 @@ export class AuthController extends PuterController {
typeof req.headers['user-agent'] === 'string'
? req.headers['user-agent']
: undefined;
// First entry of Prelude's delivery sequence — where the code actually
// went. Returned to the client so it can point the user at the right
// app (e.g. "check WhatsApp" instead of "check your texts").
let deliveryChannel: string | undefined;
try {
const result = await this.clients.prelude.createVerification(
parsed.e164,
@@ -1160,25 +1455,44 @@ export class AuthController extends PuterController {
ip,
device_id: req.deviceFingerprint ?? undefined,
user_agent: userAgent,
dispatch_id: dispatchId,
},
);
deliveryChannel = result.channels?.[0];
// Prelude rejected the attempt as abusive — surface as rate-limit.
if (
result.status === 'blocked' ||
result.status === 'shadow_blocked'
) {
throw new HttpError(
throw await this.smsSendError(
429,
'Phone verification is temporarily unavailable for this number.',
{ legacyCode: 'too_many_requests' as never },
`prelude_${result.status}`,
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{
legacyCode: 'too_many_requests' as never,
fields: fallbackFields,
},
);
}
} catch (e) {
if (e instanceof HttpError) throw e;
console.warn('[send-confirm-phone] createVerification failed:', e);
throw new HttpError(502, 'Could not send verification code.', {
legacyCode: 'upstream_error' as never,
});
throw await this.smsSendError(
502,
'Could not send verification code.',
'prelude_request_failed',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
detail: e,
},
{ legacyCode: 'upstream_error' as never },
);
}
// Tell the abuse extension a code was actually sent, so it can bump its
@@ -1199,7 +1513,10 @@ export class AuthController extends PuterController {
} catch {
// ignore — best-effort velocity signal
}
res.json({});
res.json({
...fallbackFields,
...(deliveryChannel ? { channel: deliveryChannel } : {}),
});
}
@Post('/confirm-phone', {
@@ -1281,8 +1598,14 @@ export class AuthController extends PuterController {
phone: pendingPhone,
});
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
// the abuse extension lands BEFORE we broadcast "refresh" and respond —
// otherwise the client re-fetches and still sees the card gate. Load-
// bearing now; emitAndWait swallows listener errors, so this stays
// best-effort and never blocks confirm on a listener.
try {
this.clients.event?.emit(
await this.clients.event?.emitAndWait(
'user.phone-verified' as never,
{
user_id: user.id,
@@ -1292,7 +1615,7 @@ export class AuthController extends PuterController {
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
// ignore — listeners are best-effort
}
// Notify other tabs/devices for this user so they refresh + drop the gate.
try {
@@ -1344,11 +1667,14 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (!user.requires_card_verification) {
// Phone normally comes first, but the fallback lets a phone-gated user
// in once they've exhausted SMS attempts.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1396,6 +1722,16 @@ export class AuthController extends PuterController {
// Kill switch: the extension reports the feature disabled — unstick
// any user still carrying the flag instead of dead-ending them.
if (setupEvent.enabled === false) {
// A fallback user is here BECAUSE SMS isn't working for them, and
// now the card path is off too — they stay phone-gated with no
// way through. Surface it; don't clear a gate with nothing
// verified.
if (fallbackEligible)
console.warn(
'[card-verification/setup] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1452,11 +1788,13 @@ export class AuthController extends PuterController {
throw new HttpError(404, 'User not found.', {
legacyCode: 'not_found',
});
if (!user.requires_card_verification) {
// Same fallback exception as setup: card may come before phone.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1473,6 +1811,7 @@ export class AuthController extends PuterController {
fingerprint: null as string | null,
funding: null as string | null,
country: null as string | null,
customer_id: null as string | null,
};
try {
await this.clients.event?.emitAndWait(
@@ -1486,6 +1825,12 @@ export class AuthController extends PuterController {
// Kill switch — same semantics as /card-verification/setup.
if (confirmEvent.enabled === false) {
if (fallbackEligible)
console.warn(
'[card-verification/confirm] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1503,8 +1848,15 @@ export class AuthController extends PuterController {
return;
}
// A fallback card clears the phone gate too — the point of the
// fallback. `fallbackEligible &&` makes the invariant local instead
// of leaning on the 409 guard above: only a fallback user's card can
// ever clear a phone gate.
const clearedPhoneGate =
fallbackEligible && Boolean(user.requires_phone_verification);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
try {
@@ -1516,6 +1868,7 @@ export class AuthController extends PuterController {
fingerprint: confirmEvent.fingerprint,
funding: confirmEvent.funding,
country: confirmEvent.country,
customer_id: confirmEvent.customer_id,
} as never,
{},
);
@@ -1529,11 +1882,21 @@ export class AuthController extends PuterController {
'user.card_verified',
{ original_client_socket_id },
);
// The fallback cleared the phone gate too — tell phone-gate UIs.
if (clearedPhoneGate)
await this.services.socket?.send(
{ room: user.id },
'user.phone_verified',
{ original_client_socket_id },
);
} catch {
// ignore — best-effort
}
res.json({ card_verified: true });
res.json({
card_verified: true,
...(clearedPhoneGate ? { phone_verified: true } : {}),
});
}
// -- Password recovery -------------------------------------------
@@ -2032,6 +2395,8 @@ export class AuthController extends PuterController {
requires_email_confirmation: 0,
});
await this.stores.oidc.unlinkAllByUserId(user.id);
try {
this.clients.event?.emit(
'user.email-changed' as never,
@@ -2718,6 +3083,33 @@ export class AuthController extends PuterController {
await Promise.all([userPermGrantPromise, missingFSPathPromise]);
try {
const a = app as {
id?: number;
uid?: string;
index_url?: string | null;
owner_user_id?: number | null;
name?: string | null;
};
this.clients.event?.emit(
'puter.app.authenticated' as never,
{
app_uid,
app: {
id: a.id,
uid: a.uid,
index_url: a.index_url ?? null,
owner_user_id: a.owner_user_id ?? null,
name: a.name ?? null,
},
user_id: req.actor!.user?.id ?? null,
} as never,
{},
);
} catch {
// Fine if failed
}
res.json({ token, app_uid });
}
@@ -19,6 +19,12 @@
import { Readable } from 'node:stream';
import type { Request, RequestHandler, Response } from 'express';
import { trace } from '@opentelemetry/api';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { actorUid, type Actor } from '../../core/actor.js';
@@ -615,4 +621,65 @@ describe('DriverController.registerRoutes', () => {
});
});
describe('DriverController.#handleCall tracing', () => {
let routes: Captured;
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
beforeAll(() => {
routes = captureRoutes(controller);
trace.setGlobalTracerProvider(provider);
});
afterAll(async () => {
await provider.shutdown();
trace.disable();
});
it('emits driver + downstream spans around a successful call', async () => {
const actor = await makeUserActor();
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
exporter.reset();
const res = makeRes();
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key: `k-${uuidv4()}`, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
expect((res.body as { success: boolean }).success).toBe(true);
const names = exporter.getFinishedSpans().map((s) => s.name);
const driverSpan = exporter
.getFinishedSpans()
.find((s) => s.name === 'driver.puter-kvstore.set');
expect(driverSpan).toBeDefined();
expect(driverSpan!.attributes).toMatchObject({
driver: 'puter-kvstore',
'driver.method': 'set',
'driver.name': 'puter-kvstore',
});
// The call path exercises the permission gate and the KV store's
// dynamo writes — both should have produced spans of their own.
expect(names).toContain('permission.scan');
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
});
});
void vi; // unused but reserved for future expansion
@@ -37,6 +37,7 @@ import {
import type { PermissionService } from '../../services/permission/PermissionService.js';
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { withSpan } from '../../util/span.js';
import { PuterController } from '../types.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
@@ -382,11 +383,22 @@ export class DriverController extends PuterController {
}
// Drivers read actor/context via the Context API — no drilled args.
// The span ends when the method returns; for streamed results that
// is stream start, not stream drain (same window the lifecycle
// events below report as durationMs).
const startedAt = Date.now();
let result;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (fn as (...x: unknown[]) => any).call(driver, args);
result = await withSpan(
`driver.${ifaceName}.${method}`,
{
driver: ifaceName,
'driver.method': method,
'driver.name': resolved,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (fn as (...x: unknown[]) => any).call(driver, args),
);
} catch (e) {
this.clients.event?.emit(
`driver.${ifaceName}.${method}.error`,
@@ -1992,3 +1992,111 @@ describe('FSController metadata.objectKey injection', () => {
expect(await streamToString(read.body)).toBe(attackerDecoy);
});
});
// ── associatedAppId entitlement gate ────────────────────────────────
//
// `associatedAppId` is client-supplied write metadata that's echoed back in
// legacy FS responses. Binding a file to another tenant's private app would
// turn `/stat` into an app-row enumeration oracle, so the write path drops
// any association the actor isn't entitled to make.
describe('FSController associatedAppId entitlement gate', () => {
// Seed an app row with a direct insert. `create` treats is_private as a
// read-only column, and going through the store would prime the cache —
// a raw insert leaves nothing cached so the gate's getById reads the DB.
const makeApp = async (
ownerUserId: number,
opts: { is_private?: boolean } = {},
): Promise<{ id: number }> => {
const uid = `app-${uuidv4()}`;
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?)`,
[
uid,
uid,
'Gate App',
'https://gate-app.puter.site/',
ownerUserId,
opts.is_private ? 1 : 0,
],
);
const row = (
await server.clients.db.read(
'SELECT id FROM apps WHERE uid = ?',
[uid],
)
)[0] as { id: number };
return { id: row.id };
};
// Write a file via the signed-write flow with the given associatedAppId
// and return the committed entry's stored associatedAppId.
const writeWithAssociation = async (
actor: Actor,
path: string,
associatedAppId: number,
): Promise<number | null> => {
const startRes = makeRes();
await withActor(actor, () =>
controller.startBatchWrites(
makeReq<SignedWriteRequest[]>({
body: [{ fileMetadata: { path, size: 3, associatedAppId } }],
actor,
}),
startRes.res,
),
);
const [started] = startRes.captured.body as SignedWriteResponse[];
const completeRes = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: [{ uploadId: started.sessionId }],
actor,
}),
completeRes.res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(path);
return entry?.associatedAppId ?? null;
};
it("drops an association to another tenant's private app", async () => {
const victim = await makeUser();
const attacker = await makeUser();
const victimApp = await makeApp(victim.userId, { is_private: true });
const stored = await writeWithAssociation(
attacker.actor,
`/${attacker.actor.user!.username}/Documents/probe.txt`,
victimApp.id,
);
expect(stored).toBeNull();
});
it('keeps an association to a public app the actor does not own', async () => {
const owner = await makeUser();
const other = await makeUser();
const publicApp = await makeApp(owner.userId, { is_private: false });
const stored = await writeWithAssociation(
other.actor,
`/${other.actor.user!.username}/Documents/public-assoc.txt`,
publicApp.id,
);
expect(stored).toBe(publicApp.id);
});
it('keeps an association to the actor’s own private app', async () => {
const owner = await makeUser();
const ownApp = await makeApp(owner.userId, { is_private: true });
const stored = await writeWithAssociation(
owner.actor,
`/${owner.actor.user!.username}/Documents/own-assoc.txt`,
ownApp.id,
);
expect(stored).toBe(ownApp.id);
});
});
+50 -4
View File
@@ -119,6 +119,8 @@ export class FSController extends PuterController {
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
requestBody.fileMetadata,
requestBody,
undefined,
userId,
);
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
pathAlreadyNormalized: true,
@@ -183,6 +185,7 @@ export class FSController extends PuterController {
normalizedRequestBody.fileMetadata,
normalizedRequestBody,
appUidLookupCache,
userId,
);
return normalizedRequestBody;
}),
@@ -364,6 +367,8 @@ export class FSController extends PuterController {
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
requestBody.fileMetadata,
requestBody,
undefined,
userId,
);
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
pathAlreadyNormalized: true,
@@ -497,6 +502,7 @@ export class FSController extends PuterController {
item.fileMetadata,
item,
appUidLookupCache,
userId,
),
})),
),
@@ -718,6 +724,7 @@ export class FSController extends PuterController {
normalizedRequestBody.fileMetadata,
normalizedRequestBody,
appUidLookupCache,
userId,
);
return normalizedRequestBody;
}),
@@ -1695,6 +1702,7 @@ export class FSController extends PuterController {
fileMetadata: FSEntryWriteInput,
fallbackSource?: unknown,
appUidLookupCache?: Map<string, Promise<number | null>>,
actorUserId?: number,
): Promise<FSEntryWriteInput> {
const metadataRecord = this.#toObjectRecord(fileMetadata);
const fallbackRecord = this.#toObjectRecord(fallbackSource);
@@ -1708,10 +1716,11 @@ export class FSController extends PuterController {
),
);
if (associatedAppId !== undefined) {
return {
...fileMetadata,
return this.#withAssociatedAppId(
fileMetadata,
associatedAppId,
};
actorUserId,
);
}
const appUid = this.#firstDefined(
@@ -1745,9 +1754,46 @@ export class FSController extends PuterController {
if (resolvedAppId === null) {
return fileMetadata;
}
return this.#withAssociatedAppId(
fileMetadata,
resolvedAppId,
actorUserId,
);
}
/**
* Bind `associatedAppId` onto the write input only when the actor is
* entitled to reference that app. `associatedAppId` is client-supplied
* and never trusted for authz, but it's echoed back in legacy FS
* responses — so an attacker could plant another tenant's private app id
* to confirm the row exists (an enumeration oracle) and harvest its
* metadata. Allow binding to public apps (their existence isn't secret)
* or to apps the actor owns; drop the association otherwise so the file
* simply carries no associated app.
*
* `#resolveWriteFileMetadata` has already copied the raw client value onto
* `fileMetadata`, so a dropped association must be actively stripped — not
* merely left unset.
*/
async #withAssociatedAppId(
fileMetadata: FSEntryWriteInput,
appId: number,
actorUserId?: number,
): Promise<FSEntryWriteInput> {
const app = await this.stores.app.getById(appId);
const isPrivate =
!!app && (Boolean(app.is_private) || Boolean(app.protected));
const isOwner =
actorUserId !== undefined &&
this.#toNumber(app?.owner_user_id) === actorUserId;
if (!app || (isPrivate && !isOwner)) {
const { associatedAppId: _dropped, ...rest } =
fileMetadata as unknown as Record<string, unknown>;
return rest as unknown as FSEntryWriteInput;
}
return {
...fileMetadata,
associatedAppId: resolvedAppId,
associatedAppId: appId,
};
}
@@ -765,6 +765,121 @@ describe('LegacyFSController.copy', () => {
`/${username}/Pictures/renamed-copy`,
);
});
it('copies an empty file (no backing S3 object) without erroring', async () => {
// Empty files created via /touch have size 0 and no S3 object —
// bucket is null. Copy must clone them as empty-file entries rather
// than issuing a CopyObject, which would throw S3 NoSuchKey.
const { actor } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/empty.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: src }, actor }),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.copy(
makeReq({
body: { source: src, destination: `/${username}/Pictures` },
actor,
}),
res,
),
);
const body = captured.body as Array<{ copied: { path: string } }>;
expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`);
const copied = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures/empty.txt`,
);
expect(copied).not.toBeNull();
expect(copied!.size).toBe(0);
// Original is untouched.
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
});
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
// A real file whose backing S3 object has vanished keeps a non-null
// bucket, so it isn't an empty file. CopyObject would throw S3
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
// rather than bubbling a 500. Mirrors readContent's ghost handling.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/ghost.txt`;
const content = Buffer.from('i will be deleted from s3');
await server.services.fs.write(userId, {
fileMetadata: {
path: src,
size: content.byteLength,
contentType: 'text/plain',
},
fileContent: content,
});
// Delete the backing S3 object directly, leaving the DB row behind.
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
await server.stores.s3Object.deleteObject(
server.stores.s3Object.resolveBucket(entry.bucket),
entry.uuid,
server.stores.s3Object.resolveRegion(entry.bucketRegion),
);
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.copy(
makeReq({
body: {
source: src,
destination: `/${username}/Pictures`,
},
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
// The ghost handler removed the orphaned source row.
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
// No partial copy was left behind.
expect(
await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures/ghost.txt`,
),
).toBeNull();
});
it('reads an empty file as empty content without deleting it', async () => {
// Reading an empty file (no S3 object) must not throw NoSuchKey nor
// trip the ghost-file cleanup, which would delete the entry.
const { actor } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/readme-empty.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: src }, actor }),
makeRes().res,
),
);
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
const download = await server.services.fs.readContent(entry);
const chunks: Buffer[] = [];
for await (const chunk of download.body) {
chunks.push(Buffer.from(chunk as Uint8Array));
}
expect(Buffer.concat(chunks).byteLength).toBe(0);
expect(download.contentLength).toBe(0);
// The entry must still exist — the ghost handler must NOT have run.
expect(
await server.stores.fsEntry.getEntryByPath(src),
).not.toBeNull();
});
});
// ── move ────────────────────────────────────────────────────────────
@@ -158,8 +158,7 @@ export class LegacyFSController extends PuterController {
router.get('/get-launch-apps', apiOptions, async (req, res) => {
const recommendedSvc = this.services.recommendedApps as unknown as
| { getRecommendedApps?: () => Promise<unknown[]> }
| undefined;
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
const recommended = recommendedSvc?.getRecommendedApps
? await recommendedSvc.getRecommendedApps()
: [];
@@ -194,6 +193,11 @@ export class LegacyFSController extends PuterController {
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// An app with no owner isn't owned by a Puter user —
// it's an "external" (origin-bootstrapped) app.
external:
app.owner_user_id == null ||
app.owner_user_id === '',
});
}
}
@@ -555,9 +559,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -973,8 +975,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1515,10 +1516,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -2196,120 +2194,6 @@ export class LegacyFSController extends PuterController {
}
// -- Helpers ---------------------------------------------------------
#parsePositiveIntegerQuery(
query: Record<string, unknown>,
key: string,
message: string,
): number | undefined {
const value = query[key];
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new HttpError(400, message, { legacyCode: 'bad_request' });
}
return parsed;
}
#parseNonNegativeIntegerQuery(
query: Record<string, unknown>,
key: string,
message: string,
): number | undefined {
const value = query[key];
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new HttpError(400, message, { legacyCode: 'bad_request' });
}
return parsed;
}
#normalizeRangeHeader(rangeHeader: string): string | undefined {
const firstRange = rangeHeader.includes(',')
? rangeHeader.split(',')[0]?.trim()
: rangeHeader.trim();
if (!firstRange) return undefined;
const matches = firstRange.match(/^bytes=(\d+)-(\d*)$/);
if (!matches) return undefined;
const [, start, end] = matches;
return end ? `bytes=${start}-${end}` : `bytes=${start}-`;
}
#pipeLimitedLines(
source: NodeJS.ReadableStream,
res: Response,
lineCount: number,
): void {
let remainingLines = lineCount;
let isClosed = false;
const closeSource = () => {
if (isClosed) return;
isClosed = true;
if ('destroy' in source && typeof source.destroy === 'function') {
source.destroy();
}
};
source.on('error', (err) => {
if (!isClosed) {
isClosed = true;
res.destroy(err);
}
});
res.on('close', () => {
closeSource();
});
source.on('data', (chunk: Buffer | string) => {
if (isClosed) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
let endIndex = buffer.length;
for (let index = 0; index < buffer.length; index++) {
if (buffer[index] !== 0x0a) continue;
remainingLines -= 1;
if (remainingLines === 0) {
endIndex = index + 1;
break;
}
}
if (endIndex > 0) {
const canContinue = res.write(buffer.subarray(0, endIndex));
if (!canContinue) {
source.pause();
res.once('drain', () => {
if (!isClosed) {
source.resume();
}
});
}
}
if (endIndex !== buffer.length) {
res.end();
closeSource();
}
});
source.on('end', () => {
if (!isClosed) {
isClosed = true;
res.end();
}
});
}
#requireActor(req: Request) {
const actor = req.actor;
if (!actor) {
@@ -0,0 +1,102 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { loadLegacyAssociatedApps } from './legacyFsHelpers.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
const entryWithApp = (associatedAppId: number): FSEntry =>
({ associatedAppId }) as unknown as FSEntry;
const appRow = (overrides: Record<string, unknown>): Record<string, unknown> => ({
id: 1,
uid: 'app-1',
owner_user_id: 99,
app_owner: 99,
icon: 'icon',
name: 'an-app',
title: 'An App',
description: 'desc',
godmode: 1,
maximize_on_start: 1,
index_url: 'https://an-app.puter.site/',
background: 1,
metadata: { secret: true },
is_private: 0,
protected: 0,
...overrides,
});
const fakeStore = (row: Record<string, unknown>) => ({
getByIds: async (ids: number[]) =>
new Map(ids.map((id) => [id, { ...row, id }])),
});
describe('loadLegacyAssociatedApps associated_app redaction', () => {
it('never leaks owner identifiers, even for public apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 0 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app).not.toHaveProperty('owner_user_id');
expect(app).not.toHaveProperty('app_owner');
});
it('passes through hosting + capability fields for public apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 0, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app.index_url).toBe('https://an-app.puter.site/');
expect(app.godmode).toBe(1);
expect(app.maximize_on_start).toBe(1);
});
it('redacts hosting + capability fields for private apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 1, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
// Existence + display fields still surface...
expect(app.uid).toBe('app-1');
expect(app.name).toBe('an-app');
expect(app.is_private).toBe(1);
// ...but the sensitive bits are stripped.
expect(app.index_url).toBeNull();
expect(app.godmode).toBe(0);
expect(app.maximize_on_start).toBe(0);
expect(app.background).toBe(0);
expect(app.metadata).toBeNull();
expect(app).not.toHaveProperty('owner_user_id');
});
it('redacts the same fields for protected apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 1, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app.protected).toBe(1);
expect(app.index_url).toBeNull();
expect(app.godmode).toBe(0);
});
});
+18 -7
View File
@@ -274,21 +274,33 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
* because the v1 wire contract emits them that way and existing clients key
* off it. Other columns pass through as-is — `metadata` is already parsed.
*
* Two redactions, because `associatedAppId` can name an app the actor does
* not own (the column is client-writable display metadata, never trusted for
* authz — see FSController) and may point cross-tenant:
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
* they're internal user references with no client-side display use.
* - For private or protected apps, the direct hosting URL and launch
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
* withholds `index_url` from callers without read entitlement. Display
* fields (name, icon, title) still pass through so the GUI can label the
* file's associated app.
*/
function mapAppForLegacyAssociatedApp(
app: Record<string, unknown>,
): Record<string, unknown> {
const gated = Boolean(app.is_private) || Boolean(app.protected);
return {
id: app.id,
uid: app.uid,
owner_user_id: app.owner_user_id,
icon: app.icon,
name: app.name,
title: app.title,
description: app.description,
godmode: toIntBool(app.godmode),
maximize_on_start: toIntBool(app.maximize_on_start),
index_url: app.index_url,
godmode: gated ? 0 : toIntBool(app.godmode),
maximize_on_start: gated ? 0 : toIntBool(app.maximize_on_start),
index_url: gated ? null : app.index_url,
approved_for_listing: toIntBool(app.approved_for_listing),
approved_for_opening_items: toIntBool(app.approved_for_opening_items),
approved_for_incentive_program: toIntBool(
@@ -297,9 +309,8 @@ function mapAppForLegacyAssociatedApp(
timestamp: app.timestamp ?? null,
last_review: app.last_review ?? null,
tags: app.tags ?? null,
app_owner: app.app_owner ?? null,
background: toIntBool(app.background),
metadata: app.metadata ?? null,
background: gated ? 0 : toIntBool(app.background),
metadata: gated ? null : (app.metadata ?? null),
protected: toIntBool(app.protected),
is_private: toIntBool(app.is_private),
};
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
expect(html).toContain('Puter');
});
it('exposes disable_temp_users to the GUI when signups are disabled', async () => {
const homepageConfig = server.controllers.homepage.config as {
disable_user_signup?: boolean;
};
const prev = homepageConfig.disable_user_signup;
homepageConfig.disable_user_signup = true;
const { res, captured } = makeRes();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prev;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
const homepageConfig = server.controllers.homepage.config as {
disable_user_signup?: boolean;
gui_params?: Record<string, unknown>;
};
const prevFlag = homepageConfig.disable_user_signup;
const prevGuiParams = homepageConfig.gui_params;
homepageConfig.disable_user_signup = false;
homepageConfig.gui_params = {
...prevGuiParams,
disable_temp_users: true,
};
const { res, captured } = makeRes();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prevFlag;
homepageConfig.gui_params = prevGuiParams;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('still serves the shell when an authenticated actor is present', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
@@ -173,12 +210,14 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
@@ -82,6 +82,9 @@ export class HomepageController extends PuterController {
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
router.get('/@:username', {}, (req, res) => sendShell(req, res));
@@ -52,6 +52,12 @@ const TEST_ORIGIN = 'http://test.local';
let server: PuterServer;
let router: PuterRouter;
// Stand-in for the abuse extension's signup veto. EventClient has no off(),
// so one shared listener is installed in beforeAll and tests swap the
// override in and out (same pattern as AuthController.test.ts).
type SignupValidateOverride = (data: Record<string, unknown>) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
beforeAll(async () => {
server = await setupTestServer({
origin: TEST_ORIGIN,
@@ -75,6 +81,14 @@ beforeAll(async () => {
} as never);
router = new PuterRouter();
server.controllers.oidc.registerRoutes(router);
server.clients.event.on(
'puter.signup.validate',
(_k: unknown, data: unknown) => {
if (signupValidateOverride) {
signupValidateOverride(data as Record<string, unknown>);
}
},
);
});
afterAll(async () => {
@@ -83,6 +97,7 @@ afterAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
signupValidateOverride = null;
});
interface CapturedResponse {
@@ -92,6 +107,7 @@ interface CapturedResponse {
redirectUrl?: string;
headers: Record<string, string>;
cookies: Array<{ name: string; value: string; opts?: unknown }>;
clearedCookies: Array<{ name: string; opts?: unknown }>;
contentType?: string;
}
@@ -100,6 +116,7 @@ const makeReq = (init: {
query?: Record<string, unknown>;
params?: Record<string, unknown>;
headers?: Record<string, string>;
cookies?: Record<string, string>;
method?: string;
}): Request => {
return {
@@ -107,6 +124,7 @@ const makeReq = (init: {
query: init.query ?? {},
params: init.params ?? {},
headers: init.headers ?? {},
cookies: init.cookies ?? {},
method: init.method ?? 'GET',
} as unknown as Request;
};
@@ -117,6 +135,7 @@ const makeRes = () => {
body: undefined,
headers: {},
cookies: [],
clearedCookies: [],
};
const res = {
json: vi.fn((value: unknown) => {
@@ -155,6 +174,10 @@ const makeRes = () => {
captured.cookies.push({ name, value, opts });
return res;
}),
clearCookie: vi.fn((name: string, opts?: unknown) => {
captured.clearedCookies.push({ name, opts });
return res;
}),
type: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
@@ -593,6 +616,7 @@ describe('OIDCController login callback', () => {
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('message=account_suspended');
// No session cookie issued for suspended accounts.
expect(captured.cookies).toHaveLength(0);
});
@@ -674,9 +698,7 @@ describe('OIDCController login callback', () => {
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
expect(captured.redirectUrl).toContain('msg_id=msg-99');
expect(captured.redirectUrl).toContain('auth_error=1');
// 'This account is suspended.' is not in ALLOWED_ERRORS, so the
// builder falls back to 'unauthorized'.
expect(captured.redirectUrl).toContain('message=unauthorized');
expect(captured.redirectUrl).toContain('message=account_suspended');
expect(captured.redirectUrl).toContain(
`opener_origin=${encodeURIComponent('http://opener.test')}`,
);
@@ -767,6 +789,291 @@ describe('OIDCController login callback', () => {
});
});
// -- Browser binding / login-CSRF --------------------------------------
describe('OIDCController signup veto (abuse harness)', () => {
const vetoWithTrail = (email: string, trailId: string) => {
signupValidateOverride = (data) => {
if (data.email !== email) return;
data.allow = false;
data.trail_id = trailId;
};
};
const stubIdp = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
it('createUserFromOIDC returns signup_blocked and the trail id as requestCode', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-svc-1');
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
);
expect(created.success).toBe(false);
expect(created.code).toBe('signup_blocked');
expect(created.requestCode).toBe('trail-svc-1');
});
it('login callback redirects with signup_blocked + request_code when first sign-in is vetoed', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-login-1');
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('action=login');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('request_code=trail-login-1');
expect(captured.cookies).toHaveLength(0);
});
it('signup callback redirects with signup_blocked + request_code when vetoed', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-signup-1');
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.redirectUrl).toContain('action=signup');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('request_code=trail-signup-1');
expect(captured.cookies).toHaveLength(0);
});
it('a veto with no trail id still redirects with signup_blocked and no request_code', async () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
signupValidateOverride = (data) => {
if (data.email !== email) return;
data.allow = false;
};
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).not.toContain('request_code');
});
});
describe('OIDCController browser binding', () => {
const NONCE_COOKIE = 'puter_oidc_nonce';
const stubIdP = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({ params: { provider: 'custom' } }),
res,
);
const nonceCookie = captured.cookies.find(
(c) => c.name === NONCE_COOKIE,
);
expect(nonceCookie).toBeTruthy();
expect(nonceCookie?.value).toBeTruthy();
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
true,
);
// The cookie value must equal the nonce baked into the signed state.
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(decoded?.nonce).toBe(nonceCookie?.value);
});
it('completes login when the nonce cookie matches the state nonce', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
const nonce = 'browser-nonce-match';
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce,
});
stubIdP(sub, email);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({
query: { code: 'c', state },
cookies: { [NONCE_COOKIE]: nonce },
}),
res,
);
// Session cookie issued; single-use nonce cookie cleared.
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
expect(
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
).toBe(true);
});
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce: 'attacker-flow-nonce',
});
// If enforcement were missing, this would resolve a user and set a
// session cookie for the victim's browser. It must not get that far.
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
// Victim's browser has no nonce cookie for the attacker's flow.
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.cookies).toHaveLength(0);
// We bail before ever exchanging the code.
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('rejects login when the nonce cookie does not match the state nonce', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
nonce: 'expected-nonce',
});
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({
query: { code: 'c', state },
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
}),
res,
);
expect(captured.redirectUrl).toContain('auth_error=1');
expect(captured.cookies).toHaveLength(0);
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
const state = oidc().signState({
provider: 'custom',
flow: 'revalidate',
user_uuid: uuidv4(),
nonce: 'reval-nonce',
});
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/revalidate',
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
expect(captured.statusCode).toBe(400);
expect(exchangeSpy).not.toHaveBeenCalled();
});
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
// No `nonce` field — mimics a state signed before this shipped.
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
stubIdP(sub, email);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state }, cookies: {} }),
res,
);
// Proceeds as before; no nonce cookie is cleared for legacy states.
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
expect(
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
).toBe(false);
});
});
// ── /auth/oidc/callback/signup ──────────────────────────────────────
describe('OIDCController signup callback', () => {
+113 -21
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
@@ -26,45 +27,63 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
const REVALIDATION_EXPIRY_SEC = 300;
// Companion cookie that binds an OIDC flow to the browser that started it.
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
// browser-binding cookie must expire together.
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
const OIDC_NONCE_EXPIRY_SEC = 600;
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
login: { account_not_found: 'signup', other: 'login' },
signup: { account_already_exists: 'login', other: 'signup' },
};
const ALLOWED_ERRORS = ['account_suspended', 'unauthorized'] as const;
// The `message` query param is clamped to these codes — the GUI maps them to
// display text. Free-text errors (which may describe internals or, for vetoed
// signups, the block reason) never reach the redirect URL.
const ALLOWED_ERRORS = [
'account_suspended',
'unauthorized',
'signup_blocked',
] as const;
function buildErrorRedirectUrl(
origin: string,
sourceFlow: string,
errorCondition: string,
message: (typeof ALLOWED_ERRORS)[number],
message: string,
stateDecoded?: Record<string, unknown>,
requestCode?: string,
): string {
const targetFlow =
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
const base = origin.replace(/\/$/, '') || '/';
const clamped = (ALLOWED_ERRORS as readonly string[]).includes(message)
? message
: 'unauthorized';
let params: URLSearchParams;
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
const params = new URLSearchParams({
params = new URLSearchParams({
embedded_in_popup: 'true',
msg_id: String(stateDecoded.msg_id),
auth_error: '1',
message: ALLOWED_ERRORS.includes(message)
? message
: 'unauthorized',
message: clamped,
action: targetFlow,
});
if (stateDecoded?.opener_origin) {
params.set('opener_origin', String(stateDecoded.opener_origin));
}
return `${base}/?${params.toString()}`;
} else {
params = new URLSearchParams({
action: targetFlow,
auth_error: '1',
message: clamped,
});
}
if (requestCode) {
params.set('request_code', requestCode);
}
const params = new URLSearchParams({
action: targetFlow,
auth_error: '1',
message: ALLOWED_ERRORS.includes(message) ? message : 'unauthorized',
});
return `${base}/?${params.toString()}`;
}
@@ -73,6 +92,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
/** Length-safe constant-time string compare (never throws on mismatch). */
function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
/**
* True iff `target` parses as a URL whose origin equals `origin`. Used to
* clamp OIDC redirect targets — `startsWith` would accept
@@ -139,6 +166,19 @@ export class OIDCController extends PuterController {
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
// Optional GUI return path so login started from /desktop or
// /dashboard lands back there. Strict whitelist — never a
// client-supplied URL (no open redirect).
const rawReturnTo = Array.isArray(req.query.return_to)
? req.query.return_to[0]
: req.query.return_to;
if (
(flow === 'login' || flow === 'signup') &&
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
) {
appRedirectUri = `${origin}${rawReturnTo}`;
}
// Popup support
const rawPopup = Array.isArray(req.query.embedded_in_popup)
? req.query.embedded_in_popup[0]
@@ -198,6 +238,17 @@ export class OIDCController extends PuterController {
statePayload.flow = 'revalidate';
}
// Bind this flow to the initiating browser: a single-use
// nonce lives both in the signed `state` and in an HttpOnly
// companion cookie. The callback requires them to match, so a
// `state` captured from an attacker's own flow can't be
// replayed in a victim's browser (login-CSRF / session
// fixation).
const browserNonce = crypto
.randomBytes(32)
.toString('base64url');
statePayload.nonce = browserNonce;
const state = this.services.oidc.signState(statePayload);
const url = await this.services.oidc.getAuthorizationUrl(
provider,
@@ -211,6 +262,15 @@ export class OIDCController extends PuterController {
{ legacyCode: 'internal_error' },
);
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
// Same flags as the session cookie: SameSite=None;Secure
// on HTTPS so the cookie survives Apple's cross-site
// form_post callback; Lax on plain-HTTP self-host.
...sessionCookieFlags(this.config),
httpOnly: true,
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
path: '/',
});
res.redirect(302, url);
},
);
@@ -224,7 +284,7 @@ export class OIDCController extends PuterController {
const loginCb = async (req: Request, res: Response) => {
const origin = this.config.origin ?? '';
const result = await this.#processCallback(req, 'login');
const result = await this.#processCallback(req, res, 'login');
if ('error' in result) {
console.warn(`OIDC login callback error: ${result.error}`);
return res.redirect(
@@ -255,8 +315,9 @@ export class OIDCController extends PuterController {
origin,
'login',
'other',
resolved.error,
resolved.code ?? 'unauthorized',
stateDecoded,
resolved.requestCode,
),
);
}
@@ -272,7 +333,7 @@ export class OIDCController extends PuterController {
origin,
'login',
'other',
'This account is suspended.',
'account_suspended',
stateDecoded,
),
);
@@ -287,7 +348,7 @@ export class OIDCController extends PuterController {
const signupCb = async (req: Request, res: Response) => {
const origin = this.config.origin ?? '';
const result = await this.#processCallback(req, 'signup');
const result = await this.#processCallback(req, res, 'signup');
if ('error' in result) {
return res.redirect(
302,
@@ -308,14 +369,18 @@ export class OIDCController extends PuterController {
(stateDecoded.referrer as string) ?? null,
);
if ('error' in resolved) {
console.warn(
`OIDC signup user resolution error: ${resolved.error}`,
);
return res.redirect(
302,
buildErrorRedirectUrl(
origin,
'signup',
'other',
'unauthorized',
resolved.code ?? 'unauthorized',
stateDecoded,
resolved.requestCode,
),
);
}
@@ -352,7 +417,7 @@ export class OIDCController extends PuterController {
req: Request,
res: Response,
): Promise<void> => {
const result = await this.#processCallback(req, 'revalidate');
const result = await this.#processCallback(req, res, 'revalidate');
if ('error' in result) {
res.status(400).send(result.error);
return;
@@ -451,7 +516,7 @@ if (window.opener) {
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
referrer?: string | null,
): Promise<
| { error: string }
| { error: string; code?: string; requestCode?: string }
| {
user: import('../../stores/user/UserStore.js').UserRow;
origin: 'linked-sub' | 'linked-email' | 'created';
@@ -497,13 +562,18 @@ if (window.opener) {
referrer,
);
if (!outcome.success || !outcome.user) {
return { error: outcome.error ?? 'Account creation failed.' };
return {
error: outcome.error ?? 'Account creation failed.',
code: outcome.code,
requestCode: outcome.requestCode,
};
}
return { user: outcome.user, origin: 'created' };
}
async #processCallback(
req: Request,
res: Response,
flow: string,
): Promise<
| { error: string }
@@ -527,6 +597,28 @@ if (window.opener) {
if (!stateDecoded || !stateDecoded.provider)
return { error: 'Invalid or expired state.' };
// Enforce the browser binding set at /start. Every state minted by
// the current /start carries a nonce, so this covers all live flows.
// States signed before this shipped have no nonce and pass through
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
// break on deploy — a caller can't forge a nonce-less state because
// /start always adds one and the state is server-signed.
const expectedNonce =
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
if (expectedNonce) {
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
// Single-use: drop the cookie regardless of the outcome.
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
if (
typeof cookieNonce !== 'string' ||
!constantTimeEqual(cookieNonce, expectedNonce)
) {
return {
error: 'This sign-in could not be verified for your browser. Please start again.',
};
}
}
const provider = String(stateDecoded.provider);
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
if (!callbackUrl) return { error: 'Invalid flow.' };
@@ -44,7 +44,9 @@ import {
} from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { RouteOptions } from '../../core/http/index.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
@@ -192,7 +194,10 @@ describe('PuterAIController.registerRoutes', () => {
// call puter-chat-completion directly), but admit the user's own
// full-access personal access token: `requireUserActor` keeps apps out
// and `allowFullAccessToken` opens the gate to a full-access PAT. Each
// proxy route carries both flags.
// proxy route carries both flags, plus the shared per-tier AI
// rate-limit / concurrency policy — these routes bypass the
// `/drivers/call` dispatch (where the driver-declared limits are
// enforced), so without the route gates they'd be unthrottled.
const userOnlyPaths = [
'/puterai/openai/v1/chat/completions',
'/puterai/openai/v1/completions',
@@ -205,6 +210,16 @@ describe('PuterAIController.registerRoutes', () => {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
...AI_RATE_LIMIT.default,
scope: 'driver:puter-chat-completion:complete',
key: expect.any(Function),
},
concurrent: {
...AI_CONCURRENT.default,
scope: 'driver:puter-chat-completion:complete',
key: expect.any(Function),
},
});
}
const modelsRoute = calls.find(
@@ -215,6 +230,46 @@ describe('PuterAIController.registerRoutes', () => {
requireAuth: false,
});
});
it('keys the proxy-route AI limits by user uuid so they share the /drivers/call buckets', () => {
const calls: Array<{ path: string; opts: RouteOptions }> = [];
const router = {
post: vi.fn((path: string, opts: RouteOptions) => {
calls.push({ path, opts });
return router;
}),
get: vi.fn(() => router),
};
controller.registerRoutes(router as never);
const route = calls.find(
(c) => c.path === '/puterai/openai/v1/chat/completions',
);
const rateLimit = route?.opts.rateLimit as {
key: (req: Request) => string;
scope: string;
};
const concurrent = route?.opts.concurrent as {
key: (req: Request) => string;
scope: string;
};
// The dispatch buckets requests as `driver:<iface>:<method>:<uid>`
// where uid is the actor's user uuid; scope + key must compose to the
// identical string or wire traffic mints a second per-user budget.
const req = makeReq({ actor: makeUserActor() });
expect(rateLimit.key(req)).toBe('u-7');
expect(concurrent.key(req)).toBe('u-7');
expect(rateLimit.scope).toBe('driver:puter-chat-completion:complete');
expect(concurrent.scope).toBe('driver:puter-chat-completion:complete');
// No-actor fallback still yields a usable (fingerprint) key rather
// than throwing or bucketing everyone together under undefined.
const anonKey = rateLimit.key(makeReq({}));
expect(typeof anonKey).toBe('string');
expect(anonKey.length).toBeGreaterThan(0);
});
});
// ── /openai/v1/chat/completions ─────────────────────────────────────
@@ -22,6 +22,7 @@ import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import { HttpError } from '../../core/http/HttpError.js';
import { RouteOptions } from '../../core/http/index.js';
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import type {
@@ -29,6 +30,7 @@ import type {
ICompleteArguments,
} from '../../drivers/ai-chat/types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterController } from '../types.js';
const GEMINI_DOWNLOAD_BASE =
@@ -49,6 +51,18 @@ const GEMINI_DOWNLOAD_BASE =
*/
export class PuterAIController extends PuterController {
registerRoutes(router: PuterRouter): void {
/**
* The wire routes call the chat driver directly instead of going
* through the `/drivers/call` dispatch, so the shared per-tier AI
* rate-limit / concurrency policy must be declared as route gates
* here. `scope` + `key` reproduce the dispatch's bucket key
* (`driver:<iface>:<method>:<uid>`) exactly, so wire traffic and
* `/drivers/call` traffic draw from one per-user budget rather than
* each surface minting its own.
*/
const aiPolicyScope = 'driver:puter-chat-completion:complete';
const aiPolicyKey = (req: Request): string =>
req.actor?.user?.uuid || computeNetworkFingerprint(req);
const apiAuthOpts = {
subdomain: 'api',
requireUserActor: true,
@@ -58,6 +72,16 @@ export class PuterAIController extends PuterController {
// personal access tokens (CLI/MCP/scripts). Apps and scoped tokens
// stay blocked.
allowFullAccessToken: true,
rateLimit: {
...AI_RATE_LIMIT.default!,
scope: aiPolicyScope,
key: aiPolicyKey,
},
concurrent: {
...AI_CONCURRENT.default!,
scope: aiPolicyScope,
key: aiPolicyKey,
},
} as RouteOptions;
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
@@ -37,17 +37,33 @@ export class SystemController extends PuterController {
) {
// -- Healthcheck ---------------------------------------------
// Delegates to ServerHealthService for the real check-based
// status. Returns `{ ok: true }` when all registered checks pass,
// or `{ ok: false, failed: [...] }` + 503 when any fail or the
// status. Returns `{ ok: true }` + 200 when all registered checks
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
// server is draining.
router.get('/healthcheck', { subdomain: '*' }, async (_req, res) => {
//
// `?ignore=a,b` disregards the named checks for this request only.
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
// `degraded` list: `ok` stays true but the response is 207 so the
// caller can tell the node is running in a degraded state.
const parseNames = (value) =>
typeof value === 'string'
? value
.split(',')
.map((name) => name.trim())
.filter(Boolean)
: [];
router.get('/healthcheck', { subdomain: '*' }, async (req, res) => {
const health = this.services.health;
if (!health || typeof health.getStatus !== 'function') {
// Fallback for boot ordering / missing service.
return res.send('ok');
}
const status = await health.getStatus();
const status = await health.getStatus({
ignore: parseNames(req.query.ignore),
degrade: parseNames(req.query['marked-degraded']),
});
if (!status.ok) return res.status(503).json(status);
if (status.degraded?.length) return res.status(207).json(status);
return res.json(status);
});
@@ -137,6 +153,7 @@ export class SystemController extends PuterController {
name: 'Puter',
version: this.config.version ?? null,
environment: this.config.env ?? 'prod',
disable_user_signup: Boolean(this.config.disable_user_signup),
});
});
@@ -18,9 +18,10 @@
*/
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { kv } from '../../util/kvSingleton.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -79,10 +80,11 @@ interface CapturedResponse {
const makeReq = (init: {
body?: unknown;
actor?: Actor;
query?: Record<string, unknown>;
}): Request => {
return {
body: init.body ?? {},
query: {},
query: init.query ?? {},
headers: {},
actor: init.actor,
} as unknown as Request;
@@ -139,6 +141,191 @@ describe('SystemController GET /healthcheck', () => {
expect(captured.body).toMatchObject({ ok: true });
expect(captured.statusCode).toBe(200);
});
it('parses ?ignore and ?marked-degraded into trimmed name lists', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockResolvedValue({ ok: true });
try {
const { res } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({
query: {
ignore: 'database-liveness, thumbnailer',
'marked-degraded': ' socket-initialized ',
},
}),
res,
);
expect(spy).toHaveBeenCalledWith({
ignore: ['database-liveness', 'thumbnailer'],
degrade: ['socket-initialized'],
});
} finally {
spy.mockRestore();
}
});
it('returns ok:true + 200 when the only failures are ignored', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockImplementation(async ({ ignore = [] } = {}) => {
const failed = ['database-liveness'].filter(
(name) => !ignore.includes(name),
);
return failed.length === 0
? { ok: true }
: { ok: false, failed };
});
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { ignore: 'database-liveness' } }),
res,
);
expect(captured.body).toEqual({ ok: true });
expect(captured.statusCode).toBe(200);
} finally {
spy.mockRestore();
}
});
it('returns ok:true + 207 when the only failures are marked degraded', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockResolvedValue({ ok: true, degraded: ['database-liveness'] });
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { 'marked-degraded': 'database-liveness' } }),
res,
);
expect(captured.body).toEqual({
ok: true,
degraded: ['database-liveness'],
});
expect(captured.statusCode).toBe(207);
} finally {
spy.mockRestore();
}
});
it('still 503s when a non-ignored failure remains', async () => {
const spy = vi
.spyOn(server.services.health, 'getStatus')
.mockImplementation(async ({ ignore = [] } = {}) => {
const failed = ['database-liveness', 'socket-initialized'].filter(
(name) => !ignore.includes(name),
);
return failed.length === 0
? { ok: true }
: { ok: false, failed };
});
try {
const { res, captured } = makeRes();
await callRoute(
'get',
'/healthcheck',
makeReq({ query: { ignore: 'database-liveness' } }),
res,
);
expect(captured.statusCode).toBe(503);
expect(captured.body).toEqual({
ok: false,
failed: ['socket-initialized'],
});
} finally {
spy.mockRestore();
}
});
});
// ── ServerHealthService.getStatus ignore / degrade filtering ────────
//
// Exercises the real service by seeding the in-process status cache
// (the kv.js singleton) it reads from, so the actual per-request
// classification runs — not a stubbed getStatus.
describe('ServerHealthService.getStatus ignore/degrade filtering', () => {
const STATUS_CACHE_KEY = 'server-health:status';
const seedStatus = (status: unknown) => {
kv.set(STATUS_CACHE_KEY, status, { EX: 5 });
};
afterEach(() => {
kv.del(STATUS_CACHE_KEY);
});
it('collapses to ok:true when every failure is ignored', async () => {
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness', 'thumbnailer'],
});
expect(status).toEqual({ ok: true });
});
it('keeps the non-ignored failures', async () => {
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
});
expect(status).toEqual({ ok: false, failed: ['thumbnailer'] });
});
it('is a no-op for a healthy status', async () => {
seedStatus({ ok: true });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
});
expect(status).toEqual({ ok: true });
});
it('ignores unknown names without affecting real failures', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
ignore: ['not-a-check'],
});
expect(status).toEqual({ ok: false, failed: ['database-liveness'] });
});
it('demotes marked failures to degraded and stays ok:true', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
degrade: ['database-liveness'],
});
expect(status).toEqual({ ok: true, degraded: ['database-liveness'] });
});
it('reports degraded alongside remaining hard failures (ok:false)', async () => {
seedStatus({
ok: false,
failed: ['database-liveness', 'socket-initialized'],
});
const status = await server.services.health.getStatus({
degrade: ['database-liveness'],
});
expect(status).toEqual({
ok: false,
failed: ['socket-initialized'],
degraded: ['database-liveness'],
});
});
it('lets ignore take precedence over degrade for the same name', async () => {
seedStatus({ ok: false, failed: ['database-liveness'] });
const status = await server.services.health.getStatus({
ignore: ['database-liveness'],
degrade: ['database-liveness'],
});
expect(status).toEqual({ ok: true });
});
});
// ── /version ────────────────────────────────────────────────────────
@@ -233,6 +420,7 @@ describe('SystemController GET /whoarewe', () => {
expect(captured.body).toMatchObject({
name: 'Puter',
environment: 'dev',
disable_user_signup: false,
});
});
});
@@ -335,6 +335,63 @@ describe('WebDAVController', () => {
});
});
describe('suspension gate', () => {
// WebDAV must also enforce the suspension gate every other authenticated
// route gets from requireAuthGate. Same single-use() dispatch means that
// middleware is never wired in, so #dispatch calls assertNotSuspended
// itself. Without it, a suspended account could read/write/delete its
// whole filesystem over the `dav` subdomain.
it('rejects a suspended session actor with 403', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
actor: {
user: {
id: 1,
uuid: 'suspended-uuid',
username: 'suspended',
suspended: true,
},
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('rejects a suspended user on the Basic-auth path with 403', async () => {
const username = `webdav-suspended-${Math.random()
.toString(36)
.slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: await bcryptHash('correct-horse', 4),
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await server.stores.user.update(created.id, {
suspended: 1,
});
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
headers: {
authorization: basicAuth(username, 'correct-horse'),
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
});
describe('unsupported methods', () => {
it('returns 405 for unknown HTTP methods', async () => {
const { res, captured } = makeRes();
@@ -23,7 +23,10 @@ import { posix as pathPosix } from 'node:path';
import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { assertVerifiedAccount } from '../../core/http/middleware/gates.js';
import {
assertNotSuspended,
assertVerifiedAccount,
} from '../../core/http/middleware/gates.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
@@ -90,14 +93,15 @@ export class WebDAVController extends PuterController {
const actor = await this.#resolveActor(req, res);
if (!actor) return; // 401 already sent
// Apply the same pending-verification gate every other authenticated
// route gets from `requireVerifiedAccount`. WebDAV dispatches every
// method off a single `router.use` with no route options, so that
// middleware is never inserted into its chain — without this call an
// account still pending email / phone / card verification could read,
// write, and delete its entire filesystem over the `dav` subdomain,
// bypassing the gate. Throws a 403 HttpError, surfaced by the catch in
// registerRoutes.
// Apply the same suspension + pending-verification gates every other
// authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`.
// WebDAV dispatches every method off a single `router.use` with no route
// options, so that middleware is never inserted into its chain — without
// these calls a suspended account (or one still pending email / phone /
// card verification) could read, write, and delete its entire filesystem
// over the `dav` subdomain, bypassing the gates. Both throw a 403
// HttpError, surfaced by the catch in registerRoutes.
assertNotSuspended(actor.user);
assertVerifiedAccount(actor.user);
// Expand `~`/`~/...` against the authenticated actor's username.
+4 -2
View File
@@ -54,9 +54,11 @@ export interface Actor {
* Session reference when authenticated via a session token (user actors)
* or an app-under-user token that carries a session. Absent for system,
* raw-app, and pure access-token actors. Used for session introspection
* and targeted logout.
* and targeted logout. `kind` mirrors the session row's kind (e.g.
* 'web', 'app', 'worker') so callers can gate on how the credential
* was minted without an extra session lookup.
*/
session?: { uid: string } | null;
session?: { uid: string; kind?: string | null } | null;
}
/** UUID of the baked-in system user (see 0025 seed migration). */
@@ -42,6 +42,13 @@ declare global {
tokenAuthFailed?: boolean;
/**
* Set when a token authenticated but its app is on the origin
* blocklist. The auth probe leaves `actor` unset; gates translate
* this into a 403 `app_blocked`.
*/
appBlocked?: { reason?: string };
requiresReauth?: {
reason: 'token_v1' | 'session_revoked' | 'session_expired';
auth_id?: string;
@@ -545,30 +545,10 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
});
});
// ── Reauth signal + KV counters ─────────────────────────────────────
// ── Reauth signal ───────────────────────────────────────────────────
describe('createAuthProbe — reauth signal', () => {
/** Capture KV increments without a real store. */
const makeKvStub = () => {
const calls: Array<{
key: string;
pathAndAmountMap: Record<string, number>;
}> = [];
return {
calls,
store: {
incr: async (args: {
key: string;
pathAndAmountMap: Record<string, number>;
}) => {
calls.push(args);
return { res: {} as Record<string, number>, usage: 0 };
},
},
};
};
it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => {
it('sets requiresReauth with a signed token for a legacy v1 token', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
@@ -576,11 +556,7 @@ describe('createAuthProbe — reauth signal', () => {
// The probe must still set requiresReauth; gate emits 401.
actor: { user: { uuid: 'u-legacy' } },
} as never);
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
@@ -590,109 +566,36 @@ describe('createAuthProbe — reauth signal', () => {
auth_id: 'u-legacy',
reauth_token: 'reauth-jwt:u-legacy',
});
// Both increments fire under the same day-bucketed key.
expect(kv.calls).toHaveLength(1);
expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/);
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.token_v1': 1,
});
});
it('sets requiresReauth and counts reauth.session_revoked', async () => {
it('sets requiresReauth for a revoked session', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
});
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer tok' } }),
);
expect(req.requiresReauth?.reason).toBe('session_revoked');
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.session_revoked': 1,
});
});
it('sets requiresReauth and counts reauth.session_expired', async () => {
it('sets requiresReauth for an expired session', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'session_expired', auth_id: 'u-1' },
});
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer tok' } }),
);
expect(req.requiresReauth?.reason).toBe('session_expired');
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.session_expired': 1,
});
});
it('counts v2 verify (no reauth) for a healthy token', async () => {
it('attaches an actor and no reauth for a healthy v2 token', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(req.actor).toBeTruthy();
expect(req.requiresReauth).toBeUndefined();
expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 });
});
it('does not increment counters when no token is presented', async () => {
const stub = makeStubAuth();
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
await runProbe(probe, makeReq({}));
// Anonymous-OK routes pay no metrics cost.
expect(kv.calls).toHaveLength(0);
});
it('absorbs KV failures — never rejects on the hot path', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
// Failing KV: increments throw. Probe must still succeed.
const failingKv = {
incr: async () => {
throw new Error('kv unavailable');
},
};
const probe = createAuthProbe({
authService: stub.service,
kvStore: failingKv,
});
const { req, next } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(next).toHaveBeenCalledWith();
expect(req.actor).toBeTruthy();
});
it('works without a kvStore (counters become no-ops)', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
// Production may not always pass a KV — fresh installs without
// dynamo wired still need a functioning probe.
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
@@ -702,6 +605,17 @@ describe('createAuthProbe — reauth signal', () => {
expect(req.requiresReauth).toBeUndefined();
});
it('never rejects on the hot path', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
const probe = createAuthProbe({ authService: stub.service });
const { req, next } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(next).toHaveBeenCalledWith();
expect(req.actor).toBeTruthy();
});
it('logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event', async () => {
// The log line is the human-facing forensic counterpart to the
// KV counter. Asserting the exact shape so ops can `grep
+8 -25
View File
@@ -22,7 +22,6 @@ import type {
AuthService,
ReauthReason,
} from '../../../services/auth/AuthService';
import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore';
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
// wherever this middleware is imported.
@@ -32,25 +31,8 @@ interface AuthProbeOptions {
authService: AuthService;
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
cookieName?: string;
kvStore?: SystemKVStore;
}
const authV2MetricsKey = (): string => {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const dd = String(now.getUTCDate()).padStart(2, '0');
return `auth-v2:metrics:${yyyy}-${mm}-${dd}`;
};
const bumpCounter = (
kv: Pick<SystemKVStore, 'incr'> | undefined,
pathAndAmountMap: Record<string, number>,
): void => {
if (!kv) return;
kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {});
};
/**
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
* every request, tries to locate a token in the usual places, and — if one
@@ -70,7 +52,7 @@ const bumpCounter = (
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
const { authService, cookieName, kvStore } = opts;
const { authService, cookieName } = opts;
return async (req, _res, next): Promise<void> => {
// If something upstream already attached an actor, respect it.
if (req.actor) {
@@ -94,10 +76,6 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
});
if (result.reauth) {
bumpCounter(kvStore, {
v1: 1,
[`reauth.${result.reauth.reason}`]: 1,
});
// Bind a short-lived JWT proving the rejected session
// identified this auth_id. The GUI echoes this back on
// /login or /signup; the raw auth_id is informational
@@ -113,8 +91,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
console.info(
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
);
} else if (result.actor) {
bumpCounter(kvStore, { v2: 1 });
}
if (result.blocked) {
// App is on the origin blocklist: leave `actor` unset so gates
// reject. `appBlocked` lets the gate emit a clear 403 instead
// of the generic "token failed" 401.
req.appBlocked = { reason: result.blocked.reason };
}
if (result.actor) {
@@ -55,13 +55,15 @@ function readDeviceFingerprint(req: {
* Stamp request-scoped fingerprints onto `req` so any downstream gate, handler,
* or service (via the ALS `Context.get('req')`) can read them without recomputing:
*
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the same
* value the rate limiter keys on). Server-derived, so it can't be forged
* away, but it's coarse (shared behind NAT/VPN, rotates with UA).
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the
* anchor of the rate limiter's default key). Server-derived, so it can't be
* forged away, but it's coarse (shared behind NAT/VPN, rotates with UA).
* - `req.deviceFingerprint` — set only when the client supplied a well-shaped
* device fingerprint (ThumbmarkJS hash) in the body or the
* `x-puter-device-fingerprint` header; `undefined` otherwise. Client-supplied
* and spoofable, but stable per real device across IP rotation.
* and spoofable, but stable per real device across IP rotation. The rate
* limiter's 'fingerprint' strategy appends it to the network hash so each
* device behind a shared network gets its own bucket.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
@@ -407,6 +407,113 @@ describe('adminOnlyGate', () => {
}),
).toBeUndefined();
});
// -- Root-token requirement --
//
// Admin endpoints require a root token (an actor with no app anywhere
// in its token chain), so a third-party app an admin authorized can't
// reach them on the admin's behalf.
it('admits an admin acting via a session (root token)', () => {
const got = runGate(adminOnlyGate(), {
actor: { user: { uuid: 'u-1', username: 'admin' } },
});
expect(got).toBeUndefined();
});
it("admits an admin's full-access PAT (still a root token — no app)", () => {
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1', username: 'admin' } },
fullAccess: true,
},
},
});
expect(got).toBeUndefined();
});
it('rejects an admin acting through an app with 403 (not a root token)', () => {
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
});
expectHttpError(got, 403, 'forbidden');
});
it('rejects an admin access token issued through an app (app in the token chain)', () => {
// Access-token actors carry their app on `accessToken.issuer.app`,
// not top-level `actor.app` — the root-token check must walk the
// chain, not just the top level.
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('rejects an app-issued access token even when appGated', () => {
// The appGated deferral only applies to direct app-under-user
// actors: `allowedAppIdsGate` reads top-level `actor.app` and would
// pass a chain-only app straight through, so it must not be
// deferred to.
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
accessToken: {
uid: 'tok-1',
issuer: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('admits an admin acting through an app when appGated (allowedAppIdsGate then decides)', () => {
// On an appId-gated route the root-token check is deferred to
// `allowedAppIdsGate`; this gate must let the app actor through.
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: {
user: { uuid: 'u-1', username: 'admin' },
app: { uid: 'app-1' },
},
});
expect(got).toBeUndefined();
});
it('still admits a root token when appGated', () => {
const got = runGate(adminOnlyGate([], { appGated: true }), {
actor: { user: { uuid: 'u-1', username: 'admin' } },
});
expect(got).toBeUndefined();
});
it('applies the username check before the root-token check', () => {
// A non-admin acting through an app is rejected for being non-admin,
// regardless of the app scope.
const got = runGate(adminOnlyGate(), {
actor: {
user: { uuid: 'u-1', username: 'random-user' },
app: { uid: 'app-1' },
},
});
expectHttpError(got, 403, 'forbidden');
});
});
// ── requireVerifiedGate ─────────────────────────────────────────────
+54 -10
View File
@@ -18,6 +18,7 @@
*/
import type { Request, RequestHandler } from 'express';
import { effectiveActorApp } from '../../actor';
import { HttpError } from '../HttpError';
import { assertVerifiedEmail } from '../verifiedEmail';
@@ -79,16 +80,24 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
*/
export const requireAuthGate = (): RequestHandler => {
return (req, _res, next) => {
if (req.appBlocked) {
next(
new HttpError(
403,
'This app is not allowed to access Puter resources',
{ legacyCode: 'app_blocked' },
),
);
return;
}
if (!req.actor) {
next(rejectAuth(req));
return;
}
if (req.actor.user.suspended) {
next(
new HttpError(403, 'Account suspended', {
legacyCode: 'forbidden',
}),
);
try {
assertNotSuspended(req.actor.user);
} catch (err) {
next(err);
return;
}
next();
@@ -174,13 +183,24 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
* the supplied extras. Extras are *additional* allowed users on top of the
* built-in pair, not a replacement for it.
*
* Implies `requireAuth`. Does *not* imply `requireUserActor` — admin
* endpoints are callable via an admin's access token or app-under-user
* actor; combine with `requireUserActor` explicitly if a route must be
* restricted to browser sessions.
* Also requires a *root token* — an actor with no app anywhere in its token
* chain (see `effectiveActorApp`) — so a third-party app an admin has
* authorized can't reach admin endpoints on the admin's behalf. The one
* exception is `appGated`: on a route that is also appId-gated
* (`allowedAppIds`), a direct app-under-user actor is deferred to
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
* scoped to an allowed app". Access tokens issued through an app are
* rejected even then — `allowedAppIdsGate` only sees top-level `actor.app`
* and would otherwise wave them through.
*
* Implies `requireAuth`. Does *not* imply `requireUserActor` — a root token
* still includes an admin's full-access personal access token, not only
* browser sessions; combine with `requireUserActor` explicitly if a route
* must be restricted to browser sessions.
*/
export const adminOnlyGate = (
extras: readonly string[] = [],
opts: { appGated?: boolean } = {},
): RequestHandler => {
// Match the case-insensitivity guarantee of the username column
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
@@ -199,6 +219,20 @@ export const adminOnlyGate = (
);
return;
}
// Root-token requirement: reject actors carrying an app anywhere in
// their token chain — app-under-user, or an access token issued
// through an app. A direct app-under-user actor is deferred to
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
// are rejected even then, since that gate can't see them.
const chainApp = req.actor ? effectiveActorApp(req.actor) : null;
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
next(
new HttpError(403, 'Only admins may request this resource', {
legacyCode: 'forbidden',
}),
);
return;
}
next();
};
};
@@ -302,6 +336,16 @@ export const assertVerifiedAccount = (
}
};
export const assertNotSuspended = (
user: { suspended?: unknown } | undefined,
): void => {
if (user?.suspended) {
throw new HttpError(403, 'Account suspended', {
legacyCode: 'forbidden',
});
}
};
/**
* Reject unless the actor is acting through one of the named apps.
* App-under-user actors are permitted iff `actor.app.uid` is in the allowList;
@@ -0,0 +1,150 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { RequestHandler } from 'express';
import { Readable } from 'node:stream';
import type { puterClients } from '../../../clients';
import type { puterServices } from '../../../services';
import type { puterStores } from '../../../stores';
import type { IConfig, LayerInstances } from '../../../types';
interface Layers {
clients: LayerInstances<typeof puterClients>;
stores: LayerInstances<typeof puterStores>;
services: LayerInstances<typeof puterServices>;
}
// Local analogue of the production `<name>.puter.work` worker domain. Requests
// to `<name>.workers.puter.localhost` are dispatched into a Miniflare instance
// by `LocalWorkerService`, which mirrors the real Cloudflare dispatch path.
const WORKER_HOST_SUFFIX = 'workers.puter.localhost';
// Minimal WHATWG-Response shape we consume from Miniflare's `dispatchFetch`.
// It isn't the Node global `Response`, so we type it structurally rather than
// importing Miniflare's classes into the HTTP layer.
interface FetchResponse {
status: number;
headers: { forEach(cb: (value: string, key: string) => void): void };
body: ReadableStream<Uint8Array> | null;
}
function normalizeHost(value: string | undefined | null): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().toLowerCase().replace(/^\./, '');
if (!trimmed) return null;
return trimmed.split(':')[0] || null;
}
// `<name>.workers.puter.localhost` → `name`. Returns null for the bare zone or
// any host outside it. Flip this if your dev DNS puts the name elsewhere.
function workerNameFromHost(host: string): string | null {
if (host === WORKER_HOST_SUFFIX) return null;
if (!host.endsWith(`.${WORKER_HOST_SUFFIX}`)) return null;
const prefix = host.slice(0, host.length - WORKER_HOST_SUFFIX.length - 1);
return prefix.split('.')[0] || null;
}
// Express (Node) request → WHATWG Request the Worker's `fetch(request)` sees.
// Must run BEFORE any body-parsing middleware so `req` is still an unconsumed
// stream; otherwise the Worker gets an empty body on POST/PUT.
function toFetchRequest(req: Parameters<RequestHandler>[0]): Request {
const url = `http://${req.headers.host ?? WORKER_HOST_SUFFIX}${req.originalUrl}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v);
} else if (value != null) {
headers.set(key, value);
}
}
const method = (req.method ?? 'GET').toUpperCase();
const hasBody = method !== 'GET' && method !== 'HEAD';
return new Request(url, {
method,
headers,
// `duplex: 'half'` is required by undici whenever a stream body is set.
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
...(hasBody ? { duplex: 'half' } : {}),
} as RequestInit);
}
// WHATWG Response from the Worker → Express response.
function sendFetchResponse(
res: Parameters<RequestHandler>[1],
response: FetchResponse,
): void {
res.status(response.status);
response.headers.forEach((value, key) => {
// Node manages framing headers itself; forwarding them corrupts the
// response (double content-length, stale transfer-encoding).
const lower = key.toLowerCase();
if (lower === 'content-length' || lower === 'transfer-encoding') return;
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const nodeStream = Readable.fromWeb(response.body as never);
nodeStream.on('error', () => res.destroy());
nodeStream.pipe(res);
}
/**
* Serves local Workers on `*.workers.puter.localhost` by dispatching into
* Miniflare via `LocalWorkerService`. No-op unless `config.workers.localServer`
* is set — production keeps hitting real Cloudflare through `WorkerDriver`.
*
* Mount this BEFORE the body-parsing middleware in `server.ts` so the Worker
* receives the raw request stream.
*/
export const createLocalWorkerProxyMiddleware = (
config: IConfig,
layers: Layers,
): RequestHandler => {
if (!config.workers?.localServer) {
return (_req, _res, next) => next();
}
const localWorkerService = layers.services.localworkerservice;
return async (req, res, next) => {
const host = normalizeHost(req.hostname);
if (!host) return next();
const workerName = workerNameFromHost(host);
if (!workerName) return next();
try {
const fetchRequest = toFetchRequest(req);
const response = (await localWorkerService.cfCallLocal(
workerName,
fetchRequest,
)) as unknown as FetchResponse;
sendFetchResponse(res, response);
} catch (err) {
next(err);
}
};
};
+48 -11
View File
@@ -18,6 +18,7 @@
*/
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
/**
@@ -256,8 +257,32 @@ async function acquireKvConcurrent(kv, key, limit) {
// limiting — so route / driver code never has to reason about which
// backend is wired for which mode.
/**
* Wrap a backend pair so every rate / acquire call runs inside a span
* tagged with the backend name. Applied at registration, so all gates
* (route middleware, driver helpers, imperative checks) are covered.
*/
function instrumentBackendPair(name, pair) {
const attrs = { 'rate_limit.backend': name };
return {
rate: (key, limit, windowMs) =>
withSpan('rate_limit.check', attrs, () =>
pair.rate(key, limit, windowMs),
),
acquire: (key, limit) =>
withSpan('rate_limit.acquire', attrs, () =>
pair.acquire(key, limit),
),
};
}
const memoryBackendPair = instrumentBackendPair('memory', {
rate: checkMemory,
acquire: acquireMemoryConcurrent,
});
const backends = {
memory: { rate: checkMemory, acquire: acquireMemoryConcurrent },
memory: memoryBackendPair,
};
let defaultBackendName = 'memory';
@@ -295,19 +320,19 @@ export function configureRateLimit({
} = {}) {
// Reset (test reconfigure clears stale wiring).
for (const name of Object.keys(backends)) delete backends[name];
backends.memory = { rate: checkMemory, acquire: acquireMemoryConcurrent };
backends.memory = memoryBackendPair;
if (redis) {
backends.redis = {
backends.redis = instrumentBackendPair('redis', {
rate: (key, limit, windowMs) =>
checkRedis(redis, key, limit, windowMs),
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
};
});
}
if (kv) {
backends.kv = {
backends.kv = instrumentBackendPair('kv', {
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
};
});
}
meteringService = metering ?? null;
@@ -351,9 +376,10 @@ function resolveBackend(name) {
* Build a rate-limit key from the request.
*
* Strategies:
* 'fingerprint' — IP + User-Agent hash (default). Good for
* unauthenticated endpoints where the same IP may
* serve many users (offices, VPNs).
* 'fingerprint' — network hash (IP + headers), refined by the client's
* device fingerprint when one was supplied (default).
* Good for unauthenticated endpoints where the same
* IP may serve many users (offices, VPNs).
* 'ip' — bare IP. Simpler but coarser.
* 'user' — actor UUID. Use for authenticated endpoints where
* you want per-account limits regardless of IP.
@@ -395,7 +421,7 @@ function ip(req) {
/**
* A coarse network fingerprint for a request: a short hash of the (proxy-aware)
* IP plus the headers a client can't trivially vary per-request without also
* changing how the request looks. Used as the default rate-limit key here, and
* changing how the request looks. Anchors the default rate-limit key here, and
* exported so the global fingerprint middleware can stamp the identical value
* on `req.networkFingerprint` (one key space shared by both).
*/
@@ -413,8 +439,19 @@ export function computeNetworkFingerprint(req) {
.slice(0, 16);
}
/**
* The device fingerprint (validated and stamped by the fingerprint middleware)
* refines the bucket so devices behind one NAT don't crowd each other's limit.
* It stays anchored to the network hash because the value is client-supplied:
* alone it could be spoofed to drain another device's bucket, and rotating it
* to mint fresh buckets is caught by the same stacked 'ip' backstop that
* catches User-Agent rotation.
*/
function fingerprint(req) {
return computeNetworkFingerprint(req);
const network = req.networkFingerprint ?? computeNetworkFingerprint(req);
return req.deviceFingerprint
? `${network}:${req.deviceFingerprint}`
: network;
}
// -- Route middleware ------------------------------------------------
@@ -67,6 +67,7 @@ describe('rateLimitGate — memory backend (default)', () => {
actor: init.actor,
route: init.route,
socket: init.socket ?? { remoteAddress: '1.2.3.4' },
deviceFingerprint: init.deviceFingerprint,
});
it('admits up to `limit` hits and rejects the next one with 429', async () => {
@@ -140,6 +141,62 @@ describe('rateLimitGate — memory backend (default)', () => {
expect(isHttpError(re)).toBe(true);
});
it("'fingerprint' (default) gives each device behind one network its own bucket", async () => {
// Same IP and identical headers (a NAT'd office of look-alike
// machines) but distinct device fingerprints (stamped by the
// fingerprint middleware) → independent buckets, so one device
// hitting its limit doesn't block the whole network.
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-device' };
const shared = {
ip: '5.6.7.8',
headers: { 'user-agent': 'shared-browser' },
};
expect(
await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
),
).toBeUndefined();
expect(
await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-bob-02' }),
),
).toBeUndefined();
// Same device again → its own bucket is full.
const re = await runGate(
opts,
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
);
expect(isHttpError(re)).toBe(true);
});
it('device fingerprint stays anchored to the network — spoofing a value from another IP cannot drain its bucket', async () => {
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-anchor' };
const fp = 'device-victim-01';
// Victim exhausts their bucket from their own network.
expect(
await runGate(
opts,
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
),
).toBeUndefined();
const re = await runGate(
opts,
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
);
expect(isHttpError(re)).toBe(true);
// Attacker replays the same device fingerprint from elsewhere:
// different network hash → different bucket, and the victim's
// bucket is untouched.
expect(
await runGate(
opts,
makeReq({ ip: '99.99.99.99', deviceFingerprint: fp }),
),
).toBeUndefined();
});
// Stacked gates, mirroring the materializer's handling of a
// `rateLimit: [...]` array (each entry is its own gate; a rejection
// short-circuits the chain). This is the credential-endpoint pattern:
+13 -5
View File
@@ -121,9 +121,15 @@ export interface RouteOptions {
* extras in this array. `true` means just `admin`/`system`; an array adds
* to that pair (does not replace it). Implies `requireAuth`.
*
* Does NOT imply `requireUserActor` — admin endpoints accept an admin's
* access-token or app-under-user actor. Combine with `requireUserActor`
* to restrict to browser sessions.
* Also requires a *root token* (an actor with no app anywhere in its
* token chain), so an admin acting through a third-party app can't reach
* the route. Pair with `allowedAppIds` to make an admin route reachable
* by specific apps: the combination admits a root token OR a token
* scoped to an allowed app.
*
* Does NOT imply `requireUserActor` — a root token still includes an
* admin's full-access personal access token, not only browser sessions.
* Combine with `requireUserActor` to restrict to browser sessions.
*/
adminOnly?: boolean | string[];
@@ -210,8 +216,10 @@ export interface RouteOptions {
* request identity.
*
* `key` controls how requests are bucketed:
* - `'fingerprint'` (default) — IP + User-Agent hash. Safe for
* shared IPs (offices, VPNs).
* - `'fingerprint'` (default) — network hash (IP + headers),
* refined by the client's device fingerprint when one was
* supplied. Safe for shared IPs (offices, VPNs): each device
* gets its own bucket instead of the whole network sharing one.
* - `'ip'` — bare IP address.
* - `'user'` — actor's user ID. Use for authenticated routes
* where you want per-account limits.
@@ -268,6 +268,7 @@ export class ClaudeProvider implements IChatProvider {
// Other models require temperature=1 when thinking is enabled.
const omitsTemperature = [
'claude-fable-5',
'claude-sonnet-5',
'claude-opus-4-7',
'claude-opus-4-8',
].includes(modelUsed.id);
@@ -278,6 +279,7 @@ export class ClaudeProvider implements IChatProvider {
: (temperature ?? 0);
const supportsEffort = [
'claude-fable-5',
'claude-sonnet-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
@@ -50,6 +50,35 @@ export const CLAUDE_MODELS: IChatModel[] = [
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-sonnet-5',
id: 'claude-sonnet-5',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-06-30',
aliases: [
'claude-sonnet',
'claude-sonnet-latest',
'claude-sonnet-5-latest',
'claude-sonnet-5',
'anthropic/claude-sonnet-5',
],
name: 'Claude Sonnet 5',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 300,
ephemeral_5m_input_tokens: 300 * 1.25,
ephemeral_1h_input_tokens: 300 * 2,
cache_read_input_tokens: 300 * 0.1,
output_tokens: 1500,
},
context: 1000000,
max_tokens: 64000,
},
{
puterId: 'anthropic:anthropic/claude-opus-4-8',
id: 'claude-opus-4-8',
@@ -119,8 +148,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
knowledge: '2025-08',
release_date: '2026-02-17',
aliases: [
'claude-sonnet-latest',
'claude-sonnet',
'claude-sonnet-4-6-latest',
'claude-sonnet-4.6',
'claude-sonnet-4-6',
@@ -23,6 +23,74 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const OPEN_AI_MODELS: IChatModel[] = [
{
puterId: 'openai:openai/gpt-5.6-sol',
id: 'gpt-5.6-sol',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: [
'gpt-5.6',
'gpt-5.6-sol',
'openai/gpt-5.6',
'openai/gpt-5.6-sol',
],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 500,
cached_tokens: 50,
completion_tokens: 3000,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.6-terra',
id: 'gpt-5.6-terra',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
cached_tokens: 25,
completion_tokens: 1500,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.6-luna',
id: 'gpt-5.6-luna',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-02-16',
aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
cached_tokens: 10,
completion_tokens: 600,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.5',
id: 'gpt-5.5-2026-04-23',
@@ -21,6 +21,27 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const XAI_MODELS: IChatModel[] = [
{
puterId: 'x-ai:x-ai/grok-4.5',
id: 'grok-4.5',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-07-08',
name: 'Grok 4.5',
aliases: ['x-ai/grok-4.5', 'grok-4.5-latest'],
context: 500_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 600,
cached_tokens: 50,
},
max_tokens: 30_000,
},
{
puterId: 'x-ai:x-ai/grok-beta',
id: 'grok-beta',
@@ -55,20 +55,30 @@ import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
// driver-level tests — what we care about is the driver's routing.
// Each `generate` mock resolves to a sentinel URL the test inspects.
const { openaiImagesGenerateMock } = vi.hoisted(() => ({
const { openaiImagesGenerateMock, openaiImagesEditMock } = vi.hoisted(() => ({
openaiImagesGenerateMock: vi.fn(),
openaiImagesEditMock: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
) {
this.images = { generate: openaiImagesGenerateMock };
this.images = {
generate: openaiImagesGenerateMock,
edit: openaiImagesEditMock,
};
// xAI provider reaches its JSON edit endpoint through the SDK's post().
this.post = vi.fn();
this.chat = { completions: { create: vi.fn() } };
this.moderations = { create: vi.fn() };
this.responses = { create: vi.fn() };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
return {
OpenAI: OpenAICtor,
default: { OpenAI: OpenAICtor },
toFile: vi.fn(async () => ({ __file: true })),
};
});
const { googleAIGenerateContentMock, googleAIGenerateImagesMock } = vi.hoisted(
@@ -148,6 +158,7 @@ afterAll(async () => {
beforeEach(() => {
openaiImagesGenerateMock.mockReset();
openaiImagesEditMock.mockReset();
googleAIGenerateContentMock.mockReset();
googleAIGenerateImagesMock.mockReset();
togetherImagesGenerateMock.mockReset();
@@ -176,9 +187,9 @@ describe('ImageGenerationDriver.generate authentication', () => {
it('throws 401 when no actor is on the request context', async () => {
await expect(
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
).rejects.toMatchObject({ statusCode: 401 });
});
@@ -206,10 +217,10 @@ describe('ImageGenerationDriver model catalog', () => {
const all = await driver.models();
// Every catalog id from at least one provider must be reachable.
const ids = all.map((m) => m.id);
// OpenAI catalog: dall-e-2 should be present (lowercased by buildModelMap).
expect(ids).toContain('dall-e-2');
// xAI catalog: grok-2-image should be present.
expect(ids).toContain('grok-2-image');
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
expect(ids).toContain('gpt-image-1-mini');
// xAI catalog: grok-imagine-image should be present.
expect(ids).toContain('grok-imagine-image');
});
it('list() returns ids/puterIds sorted', async () => {
@@ -224,32 +235,35 @@ describe('ImageGenerationDriver model catalog', () => {
costValue: number;
source: string;
}>;
// dall-e-2 has a 256x256 cost line — must surface in reportedCosts.
const dalle256 = reported.find(
(r) => r.usageType === 'openai-image-generation:dall-e-2:256x256',
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
const gptLine = reported.find(
(r) =>
r.usageType ===
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
);
expect(dalle256).toBeDefined();
expect(dalle256?.costValue).toBe(
OPEN_AI_IMAGE_GENERATION_MODELS.find((m) => m.id === 'dall-e-2')!
.costs['256x256'],
expect(gptLine).toBeDefined();
expect(gptLine?.costValue).toBe(
OPEN_AI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'gpt-image-1-mini',
)!.costs['low:1024x1024'],
);
expect(dalle256?.source).toBe('driver:aiImage/openai-image-generation');
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
});
});
// ── Provider routing ────────────────────────────────────────────────
describe('ImageGenerationDriver.generate provider routing', () => {
it('routes a known dall-e-2 model id to the OpenAI image provider', async () => {
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
const result = await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -260,34 +274,34 @@ describe('ImageGenerationDriver.generate provider routing', () => {
expect(replicateRunMock).not.toHaveBeenCalled();
});
it('routes a known grok-2-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://xai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'hi',
} as never),
);
// xAI's provider also uses the OpenAI mock — assert via the call args.
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.model).toBe('grok-2-image');
expect(sent.model).toBe('grok-imagine-image');
expect(sent.prompt).toBe('hi');
});
it('lowercases model lookups so case variants resolve (DALL-E-2 → dall-e-2)', async () => {
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'DALL-E-2',
model: 'GPT-Image-1-Mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -322,16 +336,16 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
width: 256,
height: 256,
width: 1024,
height: 1536,
} as never),
);
// Provider received a size derived from the ratio normalization.
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.size).toBe('256x256');
expect(sent.size).toBe('1024x1536');
});
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
@@ -369,9 +383,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -381,7 +395,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
expect(aiLogCall).toBeDefined();
const [, payload] = aiLogCall!;
const p = payload as Record<string, unknown>;
expect(p.model_used).toBe('dall-e-2');
expect(p.model_used).toBe('gpt-image-1-mini');
expect(p.service_used).toBe('openai-image-generation');
// completionId is a fresh uuid-style string per call.
expect(typeof p.completionId).toBe('string');
@@ -395,9 +409,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
await expect(
withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
),
).rejects.toThrow();
@@ -424,7 +438,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/',
} as never),
@@ -438,7 +452,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/image.png',
} as never),
@@ -455,7 +469,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/somedir/image.png',
} as never),
@@ -480,7 +494,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -509,7 +523,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '~/images/out.png',
} as never),
@@ -541,7 +555,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
const result = await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/photos/out.png',
} as never),
@@ -584,7 +598,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -602,7 +616,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/noone/dir/img.png',
} as never),
+102
View File
@@ -0,0 +1,102 @@
/*
* 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/>.
*/
/**
* Shared helpers for `input_images` (image-to-image) handling across image
* providers. `input_images` is the canonical, cross-provider field; an entry
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
* API needs base64 use these helpers to normalize URLs server-side (via the
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
* xAI) pass them through untouched.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { secureFetch } from '../../util/secureHttp.js';
import type { IGenerateParams } from './types.js';
export function isHttpUrl(s: string): boolean {
return s.startsWith('http://') || s.startsWith('https://');
}
/**
* Resolve the single input image for providers that only support one.
* Throws 400 if `input_images` carries more than one entry. Returns the
* chosen image string (URL / data-URI / raw base64) or undefined.
*/
export function resolveSingleInputImage(
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
providerLabel: string,
): string | undefined {
const imgs = params.input_images;
if (imgs && imgs.length > 1) {
throw new HttpError(
400,
`${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
{ legacyCode: 'bad_request' },
);
}
return params.input_image ?? imgs?.[0];
}
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
export function parseDataUri(
s: string,
): { base64: string; mime: string } | null {
const m = DATA_URI_PATTERN.exec(s);
if (!m) return null;
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
}
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
export async function fetchImageAsBase64(
url: string,
): Promise<{ base64: string; mime: string }> {
const res = await secureFetch(url);
if (!res.ok) {
throw new HttpError(
400,
`Failed to fetch input image (status ${res.status})`,
{ legacyCode: 'bad_request' },
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
return { base64: buffer.toString('base64'), mime };
}
/**
* Normalize any input-image string to a base64 data-URI:
* • http(s) URL → fetched via secureFetch
* • data-URI → returned as-is
* • raw base64 → wrapped with `mimeHint` (default image/png)
*/
export async function toBase64DataUri(
img: string,
mimeHint?: string,
): Promise<string> {
if (img.startsWith('data:')) return img;
if (isHttpUrl(img)) {
const { base64, mime } = await fetchImageAsBase64(img);
return `data:${mime};base64,${base64}`;
}
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
fetchImageAsBase64Mock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
fetchImageAsBase64: fetchImageAsBase64Mock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -81,6 +91,7 @@ const makeProvider = (
);
beforeEach(() => {
fetchImageAsBase64Mock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
);
});
});
// ── input_images (canonical image-to-image field) ──────────────────
describe('CloudflareImageProvider.generate input_images', () => {
const klein9bWith = (extra: Record<string, unknown>) => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from([1, 2, 3]).buffer, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
return withTestActor(() =>
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 2000, h: 1000 },
...extra,
} as never),
);
};
const hasInputCostLine = () => {
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
return (entries as Array<{ usageType: string }>).some((e) =>
e.usageType.endsWith(':input_image_mp'),
);
};
it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
expect(hasInputCostLine()).toBe(true);
});
it('maps a singular input_image to the input image', async () => {
await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
expect(hasInputCostLine()).toBe(true);
});
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
fetchImageAsBase64Mock.mockResolvedValueOnce({
base64: 'AAAA',
mime: 'image/png',
});
await klein9bWith({ input_images: ['https://example.com/in.png'] });
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
'https://example.com/in.png',
);
expect(hasInputCostLine()).toBe(true);
});
it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 1024, h: 1024 },
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
});
});
@@ -29,6 +29,11 @@ import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
CloudflareImageModel,
} from './models.js';
import {
fetchImageAsBase64,
isHttpUrl,
resolveSingleInputImage,
} from '../../inputImage.js';
type CloudflareGenerateParams = IGenerateParams & {
steps?: number;
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
});
}
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
// Cloudflare accepts a single input image; a URL is fetched to base64
// server-side (SSRF-guarded) since the API has no URL field.
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
if (singleInput) {
options.image ??= isHttpUrl(singleInput)
? (await fetchImageAsBase64(singleInput)).base64
: singleInput;
}
const steps = this.#resolveSteps(selectedModel, options);
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
hasInputImage:
@@ -71,6 +71,16 @@ vi.mock('@google/genai', () => {
return { GoogleGenAI };
});
// Stub the URL→data-URI normalizer so URL inputs stay offline; keep the rest real.
const { toBase64DataUriMock } = vi.hoisted(() => ({
toBase64DataUriMock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
toBase64DataUri: toBase64DataUriMock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -94,6 +104,7 @@ const makeProvider = () =>
beforeEach(() => {
generateContentMock.mockReset();
generateImagesMock.mockReset();
toBase64DataUriMock.mockReset();
googleAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
@@ -272,6 +283,31 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
expect(result).toBe('data:image/png;base64,BASE64IMG');
});
it('fetches an http(s) URL input and sends it as an inlineData part', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
toBase64DataUriMock.mockResolvedValueOnce(
'data:image/png;base64,URLBYTES',
);
await withTestActor(() =>
provider.generate({
model: 'gemini-2.5-flash-image',
prompt: 'add a hat',
input_images: ['https://example.com/in.png'],
}),
);
expect(toBase64DataUriMock).toHaveBeenCalledWith(
'https://example.com/in.png',
undefined,
);
const sent = generateContentMock.mock.calls[0]![0];
expect(sent.contents).toContainEqual({
inlineData: { mimeType: 'image/png', data: 'URLBYTES' },
});
});
it('throws 400 when the SDK returns no inline image data', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce({
@@ -31,6 +31,7 @@ import type {
IImageModel,
IImageProvider,
} from '../../types.js';
import { isHttpUrl, toBase64DataUri } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const MIME_SIGNATURES: Record<string, string> = {
@@ -107,6 +108,18 @@ export class GeminiImageProvider implements IImageProvider {
input_images = [input_image];
}
// Resolve any http(s) URL inputs to base64 data-URIs server-side
// (SSRF-guarded) so the rest of the flow only deals with inline data.
if (input_images?.length) {
input_images = await Promise.all(
input_images.map((img) =>
isHttpUrl(img)
? toBase64DataUri(img, input_image_mime_type)
: img,
),
);
}
// Validate input images have detectable MIME types
if (input_images?.length) {
for (const img of input_images) {
@@ -39,6 +39,8 @@ export const GEMINI_ESTIMATED_IMAGE_TOKENS: Record<string, number> = {
'gemini-3.1-flash-image-preview:1K': 1120,
'gemini-3.1-flash-image-preview:2K': 1680,
'gemini-3.1-flash-image-preview:4K': 2520,
'gemini-3.1-flash-lite-image:1K': 1120,
};
export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
@@ -152,6 +154,43 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
'1K:1x1': 6.7,
},
},
{
puterId: 'google:google/gemini-3.1-flash-lite-image',
id: 'gemini-3.1-flash-lite-image',
name: 'Gemini 3.1 Flash Lite Image',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1K:1x1',
index_input_cost_key: 'input',
aliases: [
'gemini-3.1-flash-lite-image',
'google/gemini-3.1-flash-lite-image',
'google:google/gemini-3.1-flash-lite-image',
],
allowedQualityLevels: ['1K'], // 2K and 4K are unsupported
allowedRatios: [
{ w: 1, h: 1 },
{ w: 1, h: 4 },
{ w: 1, h: 8 },
{ w: 2, h: 3 },
{ w: 3, h: 2 },
{ w: 3, h: 4 },
{ w: 4, h: 1 },
{ w: 4, h: 3 },
{ w: 4, h: 5 },
{ w: 5, h: 4 },
{ w: 8, h: 1 },
{ w: 9, h: 16 },
{ w: 16, h: 9 },
{ w: 21, h: 9 },
],
costs: {
input: 25, // $0.25 per 1M input tokens (text/image)
output: 150, // $1.50 per 1M output tokens (text and thinking)
output_image: 3000, // $30.00 per 1M output image tokens
'1K:1x1': 3.36, // 1120 tokens @ $30/1M = $0.0336 per 1K image
},
},
// -- Imagen models (use generateImages API) --
{
@@ -20,8 +20,8 @@
/**
* Integration test for the OpenAI image generation provider.
*
* Uses `dall-e-2` at 256x256 — the cheapest OpenAI image
* configuration ($0.016/image). Skipped when
* Uses `gpt-image-1-mini` at low:1024x1024 — the cheapest OpenAI
* image configuration ($0.005/image). Skipped when
* `PUTER_TEST_AI_OPENAI_API_KEY` is unset.
*/
@@ -40,7 +40,7 @@ const ENV_VAR = 'PUTER_TEST_AI_OPENAI_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'OpenAiImageProvider (integration)',
() => {
it('returns an image url/data from dall-e-2 at 256x256', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
it('returns an image url/data from gpt-image-1-mini at low:1024x1024', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new OpenAiImageProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
@@ -48,9 +48,9 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'a tiny red dot on a white background',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
@@ -24,9 +24,9 @@
* redis) and constructs OpenAiImageProvider directly against the
* live wired `MeteringService`. The OpenAI SDK is mocked at the
* module boundary; that's the real network egress point. Covers the
* three model families the provider supports differently: dall-e-2/3
* (per-size pricing), gpt-image-* (token-priced), and gpt-image-2
* (open-ended size with the runtime-clamping normalizer).
* gpt-image-* models (token-priced), gpt-image-2 (open-ended size
* with the runtime-clamping normalizer), and image-to-image editing
* via `input_images` (the `images.edit` endpoint).
*/
import {
@@ -50,8 +50,15 @@ import { OpenAiImageProvider } from './OpenAiImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { generateMock, openAICtor } = vi.hoisted(() => ({
const { generateMock, editMock, toFileMock, openAICtor } = vi.hoisted(() => ({
generateMock: vi.fn(),
editMock: vi.fn(),
toFileMock: vi.fn(async (data: unknown, name: unknown, opts: unknown) => ({
__file: true,
name,
type: (opts as { type?: string } | undefined)?.type,
data,
})),
openAICtor: vi.fn(),
}));
@@ -61,12 +68,26 @@ vi.mock('openai', () => {
opts: unknown,
) {
openAICtor(opts);
this.images = { generate: generateMock };
this.images = { generate: generateMock, edit: editMock };
this.chat = { completions: { create: vi.fn() } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
return {
OpenAI: OpenAICtor,
default: { OpenAI: OpenAICtor },
toFile: toFileMock,
};
});
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
fetchImageAsBase64Mock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
fetchImageAsBase64: fetchImageAsBase64Mock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -88,6 +109,8 @@ const makeProvider = () =>
beforeEach(() => {
generateMock.mockReset();
editMock.mockReset();
fetchImageAsBase64Mock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -113,9 +136,16 @@ describe('OpenAiImageProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('OpenAiImageProvider model catalog', () => {
it('returns dall-e-2 as the default', () => {
it('returns gpt-image-1-mini as the default', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe('dall-e-2');
expect(provider.getDefaultModel()).toBe('gpt-image-1-mini');
});
it('no longer exposes any dall-e models', () => {
const provider = makeProvider();
expect(
provider.models().some((m) => m.id.startsWith('dall-e')),
).toBe(false);
});
it('exposes the static OPEN_AI_IMAGE_GENERATION_MODELS list verbatim', () => {
@@ -151,25 +181,6 @@ describe('OpenAiImageProvider.generate argument validation', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when DALL-E model + invalid quality+size combo cannot be priced', async () => {
const provider = makeProvider();
// dall-e-3 has no `2048x2048` cost entry; allowedRatios will snap to
// 1024x1024 first allowed entry though, so to trigger this we use a
// model with allowedRatios containing the mismatched ratio. We pass
// dall-e-2 with 256x256 but inject a hd quality (no hd:256 cost key).
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
prompt: 'hi',
ratio: { w: 256, h: 256 },
quality: 'hd',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateMock).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
@@ -181,9 +192,9 @@ describe('OpenAiImageProvider.generate credit gate', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
),
).rejects.toMatchObject({ statusCode: 402 });
@@ -191,89 +202,48 @@ describe('OpenAiImageProvider.generate credit gate', () => {
});
});
// ── DALL-E request shape & metering ────────────────────────────────
// ── Output extraction ──────────────────────────────────────────────
describe('OpenAiImageProvider.generate DALL-E request shape', () => {
const dalleResponse = {
data: [{ url: 'https://oai.example/img.png' }],
usage: undefined, // DALL-E responses don't include usage details.
};
it('forwards model + size verbatim and snaps invalid ratios to the first allowedRatio', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
prompt: 'a tiny red dot',
ratio: { w: 999, h: 999 }, // not in allowedRatios; falls back to 256x256
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.model).toBe('dall-e-2');
expect(sent.size).toBe('256x256');
// DALL-E 2 doesn't accept `quality` so the provider must omit it
// unless it's the literal 'hd' override (DALL-E 3).
expect('quality' in sent).toBe(false);
describe('OpenAiImageProvider.generate output extraction', () => {
const gptResponse = (data: unknown) => ({
data,
usage: {
input_tokens: 100,
output_tokens: 800,
input_tokens_details: {
text_tokens: 100,
image_tokens: 0,
cached_tokens: 0,
},
},
});
it('forwards quality=hd only for DALL-E 3', async () => {
it('returns response.data[0].url when the SDK returns a url', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
await withTestActor(() =>
provider.generate({
model: 'dall-e-3',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
quality: 'hd',
}),
generateMock.mockResolvedValueOnce(
gptResponse([{ url: 'https://oai.example/img.png' }]),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.model).toBe('dall-e-3');
expect(sent.quality).toBe('hd');
});
it('returns response.data[0].url and meters one output line at the size cents rate', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
expect(result).toBe('https://oai.example/img.png');
// dall-e-2 256x256 cost = 1.6 cents → 1_600_000 microcents.
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const outputEntry = (
entries as Array<{ usageType: string; costOverride: number }>
).find((e) => e.usageType.endsWith(':output'));
expect(outputEntry?.usageType).toBe(
'openai:dall-e-2:256x256:output',
);
expect(outputEntry?.costOverride).toBe(Math.ceil(1.6 * 1_000_000));
});
it('falls back to a base64 data URL when SDK returns b64_json instead of url', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ b64_json: 'AAAA' }],
});
generateMock.mockResolvedValueOnce(gptResponse([{ b64_json: 'AAAA' }]));
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
@@ -281,6 +251,120 @@ describe('OpenAiImageProvider.generate DALL-E request shape', () => {
});
});
// ── input_images / edit endpoint ───────────────────────────────────
describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => {
const editResponse = {
data: [{ b64_json: 'AAAA' }],
usage: {
input_tokens: 600,
output_tokens: 800,
input_tokens_details: {
text_tokens: 40,
image_tokens: 560,
cached_tokens: 0,
},
},
};
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
it('routes input_images to images.edit (not generate) with image files set', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: [PNG, PNG],
}),
);
expect(generateMock).not.toHaveBeenCalled();
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
expect(sent.model).toBe('gpt-image-1');
expect(sent.prompt).toBe('add a hat');
// Two input images → array of uploadables.
expect(Array.isArray(sent.image)).toBe(true);
expect(sent.image).toHaveLength(2);
});
it('meters an :input line at the image_input token rate when the edit response reports image tokens', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: [PNG],
}),
);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const inputEntry = (
entries as Array<{ usageType: string; costOverride: number }>
).find((e) => e.usageType.endsWith(':input'));
expect(inputEntry?.usageType).toBe(
'openai:gpt-image-1:low:1024x1024:input',
);
// gpt-image-1: text_input=500, image_input=1000 (cents/1M tokens).
// 40 text + 560 image tokens → (40*500 + 560*1000)/1e6 cents.
const expectedCents = (40 * 500 + 560 * 1000) / 1_000_000;
expect(inputEntry?.costOverride).toBe(Math.ceil(expectedCents * 1_000_000));
});
it('folds singular input_image into the edit path with a single uploadable', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1-mini',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_image: PNG,
}),
);
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
// Single image → not wrapped in an array.
expect(Array.isArray(sent.image)).toBe(false);
expect((sent.image as { __file?: boolean }).__file).toBe(true);
});
it('fetches an http(s) URL input and sends the bytes to images.edit', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
fetchImageAsBase64Mock.mockResolvedValueOnce({
base64: 'iVBORw0KGgo=',
mime: 'image/png',
});
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: ['https://example.com/in.png'],
}),
);
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
'https://example.com/in.png',
);
expect(generateMock).not.toHaveBeenCalled();
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
expect((sent.image as { __file?: boolean }).__file).toBe(true);
});
});
// ── gpt-image (token-priced) request shape & metering ──────────────
describe('OpenAiImageProvider.generate gpt-image-* request shape', () => {
@@ -423,7 +507,7 @@ describe('OpenAiImageProvider.generate gpt-image-2 size normalizer', () => {
// ── Output extraction error ────────────────────────────────────────
describe('OpenAiImageProvider.generate output extraction', () => {
describe('OpenAiImageProvider.generate output extraction error', () => {
it('throws 400 when SDK returns no usable image data', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({ data: [{}] });
@@ -431,9 +515,9 @@ describe('OpenAiImageProvider.generate output extraction', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -17,8 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import openai, { OpenAI } from 'openai';
import openai, { OpenAI, toFile } from 'openai';
import {
ImageEditParamsNonStreaming,
ImageGenerateParamsNonStreaming,
ImagesResponse,
} from 'openai/resources/images.js';
@@ -30,6 +31,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
interface OpenAIImageUsage {
@@ -44,7 +46,8 @@ interface OpenAIImageUsage {
/**
* OpenAI image generation provider for v2.
* Supports DALL-E 2/3 and GPT Image models.
* Supports the GPT Image models (gpt-image-1, -1-mini, -1.5, -2), including
* image-to-image editing via `input_images` (the `images.edit` endpoint).
*/
export class OpenAiImageProvider implements IImageProvider {
#meteringService: MeteringService;
@@ -59,6 +62,11 @@ export class OpenAiImageProvider implements IImageProvider {
'image_output',
];
// Rough per-image input token estimate for the up-front credit gate only.
// Actual billing uses the real `image_tokens` reported in the response.
// Mirrors the constant the Gemini image provider uses.
static #ESTIMATED_IMAGE_INPUT_TOKENS = 560;
constructor(config: { apiKey: string }, meteringService: MeteringService) {
this.#meteringService = meteringService;
this.#openai = new openai.OpenAI({
@@ -71,7 +79,7 @@ export class OpenAiImageProvider implements IImageProvider {
}
getDefaultModel(): string {
return 'dall-e-2';
return 'gpt-image-1-mini';
}
async generate({
@@ -80,6 +88,9 @@ export class OpenAiImageProvider implements IImageProvider {
test_mode,
model,
ratio,
input_image,
input_images,
input_image_mime_type,
}: IGenerateParams) {
const selectedModel =
this.models().find((m) => m.id === model) ||
@@ -89,6 +100,12 @@ export class OpenAiImageProvider implements IImageProvider {
return 'https://puter-sample-data.puter.site/image_example.png';
}
// Backwards compat: fold singular `input_image` into `input_images`.
if (input_image && (!input_images || input_images.length === 0)) {
input_images = [input_image];
}
const hasInputImages = (input_images?.length ?? 0) > 0;
if (typeof prompt !== 'string') {
throw new HttpError(400, '`prompt` must be a string', {
legacyCode: 'bad_request',
@@ -146,12 +163,17 @@ export class OpenAiImageProvider implements IImageProvider {
const estimatedPromptTokenCount =
this.#estimatePromptTokenCount(prompt);
const estimatedImageInputTokens = hasInputImages
? (input_images?.length ?? 0) *
OpenAiImageProvider.#ESTIMATED_IMAGE_INPUT_TOKENS
: 0;
const estimatedInputCostInCents = this.#calculateInputCostInCents(
selectedModel,
{
inputTokens: estimatedPromptTokenCount,
inputTokens:
estimatedPromptTokenCount + estimatedImageInputTokens,
inputTextTokens: estimatedPromptTokenCount,
inputImageTokens: 0,
inputImageTokens: estimatedImageInputTokens,
cachedInputTokens: 0,
cachedInputTextTokens: 0,
cachedInputImageTokens: 0,
@@ -174,15 +196,25 @@ export class OpenAiImageProvider implements IImageProvider {
);
}
// Build API parameters based on model
const apiParams = this.#buildApiParams(selectedModel.id, {
user: userIdentifier,
prompt,
size,
quality,
} as Partial<ImageGenerateParamsNonStreaming>);
const result = await this.#openai.images.generate(apiParams);
// With input images we use the edit endpoint (gpt-image only);
// otherwise the standard generate endpoint.
const result = hasInputImages
? await this.#openai.images.edit(
await this.#buildEditParams(
selectedModel.id,
{ user: userIdentifier, prompt, size, quality },
input_images!,
input_image_mime_type,
),
)
: await this.#openai.images.generate(
this.#buildApiParams(selectedModel.id, {
user: userIdentifier,
prompt,
size,
quality,
} as Partial<ImageGenerateParamsNonStreaming>),
);
const usage = this.#extractUsage(result);
const hasInputTokenUsage =
@@ -585,39 +617,81 @@ export class OpenAiImageProvider implements IImageProvider {
}
#buildPriceKey(model: string, quality: string, size: string) {
if (this.#isGptImageModel(model)) {
// GPT image models use format: "quality:size" - default to low if not specified
const qualityLevel = quality || 'low';
return `${qualityLevel}:${size}`;
}
// DALL-E models use format: "hd:size" or just "size"
return (quality === 'hd' ? 'hd:' : '') + size;
// All supported models are gpt-image-*, which price by "quality:size".
const qualityLevel = quality || 'low';
return `${qualityLevel}:${size}`;
}
#buildApiParams(
model: string,
baseParams: Partial<ImageGenerateParamsNonStreaming>,
): ImageGenerateParamsNonStreaming {
const apiParams = {
return {
model,
user: baseParams.user,
prompt: baseParams.prompt,
size: baseParams.size,
// Default to low quality if not specified, consistent with #buildPriceKey.
quality: baseParams.quality || 'low',
} as ImageGenerateParamsNonStreaming;
}
if (this.#isGptImageModel(model)) {
// GPT image models require the model parameter and use quality mapping
apiParams.model = model;
// Default to low quality if not specified, consistent with _buildPriceKey
apiParams.quality = baseParams.quality || 'low';
async #buildEditParams(
model: string,
baseParams: {
user?: string;
prompt?: string;
size?: string;
quality?: string;
},
input_images: string[],
mimeHint?: string,
): Promise<ImageEditParamsNonStreaming> {
const files = await Promise.all(
input_images.map((img) => this.#toUploadable(img, mimeHint)),
);
return {
model,
user: baseParams.user,
prompt: baseParams.prompt,
size: baseParams.size,
quality: baseParams.quality || 'low',
// gpt-image accepts one image or an array of images.
image: files.length === 1 ? files[0] : files,
} as ImageEditParamsNonStreaming;
}
// Accepts a public URL, a `data:<mime>;base64,...` URI, or a raw base64
// string and turns it into an uploadable file for the OpenAI edit endpoint.
// URLs are fetched server-side via the SSRF-guarded secureFetch.
async #toUploadable(img: string, mimeHint?: string) {
let mime = mimeHint ?? 'image/png';
let base64 = img;
if (isHttpUrl(img)) {
const fetched = await fetchImageAsBase64(img);
mime = fetched.mime;
base64 = fetched.base64;
} else {
// dall-e models
apiParams.model = model;
if (baseParams.quality === 'hd') {
apiParams.quality = 'hd';
const dataUri = /^data:([^;]+);base64,(.*)$/s.exec(img);
if (dataUri) {
mime = dataUri[1];
base64 = dataUri[2];
}
}
return apiParams;
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
throw new HttpError(
400,
'Invalid input image (empty or not base64)',
{
legacyCode: 'bad_request',
},
);
}
const ext = mime.split('/')[1]?.split('+')[0] || 'png';
return toFile(buffer, `image.${ext}`, { type: mime });
}
}
@@ -144,46 +144,4 @@ export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [
{ w: 1536, h: 1024 },
],
},
{
puterId: 'openai:openai/dall-e-3',
id: 'dall-e-3',
aliases: ['openai/dall-e-3'],
name: 'DALL-E 3',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1024x1024',
costs: {
'1024x1024': 4,
'1024x1792': 8,
'1792x1024': 8,
'hd:1024x1024': 8,
'hd:1024x1792': 12,
'hd:1792x1024': 12,
},
allowedQualityLevels: ['', 'hd'],
allowedRatios: [
{ w: 1024, h: 1024 },
{ w: 1024, h: 1792 },
{ w: 1792, h: 1024 },
],
},
{
puterId: 'openai:openai/dall-e-2',
id: 'dall-e-2',
aliases: ['openai/dall-e-2'],
name: 'DALL-E 2',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1024x1024',
costs: {
'256x256': 1.6,
'512x512': 1.8,
'1024x1024': 2,
},
allowedRatios: [
{ w: 256, h: 256 },
{ w: 512, h: 512 },
{ w: 1024, h: 1024 },
],
},
];
@@ -332,6 +332,55 @@ describe('TogetherImageProvider.generate request shape', () => {
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_base64).toBe('BASE64DATA');
});
it('routes a base64 input_images entry to image_base64', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['BASE64DATA'],
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_base64).toBe('BASE64DATA');
});
it('routes a URL input_images entry to the native image_url field (no fetch)', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['https://example.com/in.png'],
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_url).toBe('https://example.com/in.png');
expect(sent.image_base64).toBeUndefined();
});
it('throws 400 when more than one input image is supplied', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['BASE64A', 'BASE64B'],
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateMock).not.toHaveBeenCalled();
});
});
// ── Output extraction & error mapping ───────────────────────────────
@@ -26,6 +26,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js';
import { isHttpUrl, resolveSingleInputImage } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 };
@@ -87,6 +88,18 @@ export class TogetherImageProvider implements IImageProvider {
});
}
// Canonical `input_images` → Together's native fields. Together accepts
// a single input image: a URL goes to `image_url`, base64/data-URI to
// `image_base64` (via the existing `input_image` alias).
const singleInput = resolveSingleInputImage(params, 'Together AI');
if (singleInput) {
if (isHttpUrl(singleInput)) {
options.image_url ??= singleInput;
} else {
options.input_image ??= singleInput;
}
}
ratio = ratio || TOGETHER_DEFAULT_RATIO;
const actor = Context.get('actor');
@@ -488,6 +488,16 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [
pricing_unit: 'per-MP',
costs: { '1MP': 6 },
},
{
id: 'togetherai:ideogram/ideogram-4.0',
aliases: ['ideogram/ideogram-4.0', 'ideogram-4.0'],
costs_currency: 'usd-cents',
index_cost_key: '1MP',
name: 'ideogram/ideogram-4.0',
allowedQualityLevels: [''],
pricing_unit: 'per-MP',
costs: { '1MP': 6 },
},
{
id: 'togetherai:openai/gpt-image-1.5',
aliases: ['openai/gpt-image-1.5', 'gpt-image-1.5'],
@@ -49,8 +49,9 @@ import { XAIImageProvider } from './XAIImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { generateMock, openAICtor } = vi.hoisted(() => ({
const { generateMock, postMock, openAICtor } = vi.hoisted(() => ({
generateMock: vi.fn(),
postMock: vi.fn(),
openAICtor: vi.fn(),
}));
@@ -61,6 +62,8 @@ vi.mock('openai', () => {
) {
openAICtor(opts);
this.images = { generate: generateMock };
// Low-level post() is how the provider reaches xAI's JSON edit endpoint.
this.post = postMock;
// Some sibling providers boot through the same SDK module.
this.chat = { completions: { create: vi.fn() } };
});
@@ -71,7 +74,9 @@ vi.mock('openai', () => {
let server: PuterServer;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
let batchIncrementUsagesSpy: MockInstance<
MeteringService['batchIncrementUsages']
>;
beforeAll(async () => {
server = await setupTestServer();
@@ -86,9 +91,13 @@ const makeProvider = () =>
beforeEach(() => {
generateMock.mockReset();
postMock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
batchIncrementUsagesSpy = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
});
afterEach(() => {
@@ -121,15 +130,22 @@ describe('XAIImageProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('XAIImageProvider model catalog', () => {
it('returns grok-2-image as the default', () => {
it('returns grok-imagine-image as the default', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe('grok-2-image');
expect(provider.getDefaultModel()).toBe('grok-imagine-image');
});
it('exposes the static XAI_IMAGE_GENERATION_MODELS list verbatim', () => {
const provider = makeProvider();
expect(provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
});
it('no longer exposes the deprecated grok-2-image model', () => {
const provider = makeProvider();
expect(provider.models().some((m) => m.id === 'grok-2-image')).toBe(
false,
);
});
});
// ── test_mode bypass ────────────────────────────────────────────────
@@ -149,7 +165,7 @@ describe('XAIImageProvider.generate test_mode', () => {
);
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(generateMock).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -188,7 +204,7 @@ describe('XAIImageProvider.generate credit gate', () => {
).rejects.toMatchObject({ statusCode: 402 });
expect(generateMock).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -208,7 +224,7 @@ describe('XAIImageProvider.generate model resolution', () => {
}),
);
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
});
it('resolves an alias to its canonical id', async () => {
@@ -217,20 +233,20 @@ describe('XAIImageProvider.generate model resolution', () => {
await withTestActor(() =>
provider.generate({
// grok-image is an alias of grok-2-image.
// grok-image is an alias of grok-imagine-image.
model: 'grok-image',
prompt: 'hi',
}),
);
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
});
});
// ── Successful generation ───────────────────────────────────────────
describe('XAIImageProvider.generate success path', () => {
it('returns the URL from response.data[0].url and meters one image', async () => {
it('returns the URL from response.data[0].url and meters one image at the 1k output rate', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://x.ai/img/abc' }],
@@ -238,22 +254,48 @@ describe('XAIImageProvider.generate success path', () => {
const result = await withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
);
expect(result).toBe('https://x.ai/img/abc');
// No input images → generate endpoint, not the edit endpoint.
expect(postMock).not.toHaveBeenCalled();
// One increment, at the model's per-image rate (7 cents → 7,000,000 ucents).
const grok = XAI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'grok-2-image',
(m) => m.id === 'grok-imagine-image',
)!;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('xai:grok-2-image:output');
expect(count).toBe(1);
expect(cost).toBe(grok.costs.output * 1_000_000);
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
expect(entries).toHaveLength(1);
const out = (
entries as Array<{ usageType: string; costOverride: number }>
)[0];
expect(out.usageType).toBe('xai:grok-imagine-image:output:1k');
expect(out.costOverride).toBe(grok.costs['output:1k'] * 1_000_000);
});
it('uses the 2k output rate when quality is "2k"', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://x.ai/img/2k' }],
});
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image-quality',
prompt: 'hi',
quality: '2k',
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.resolution).toBe('2k');
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
expect(
(entries as Array<{ usageType: string }>)[0].usageType,
).toBe('xai:grok-imagine-image-quality:output:2k');
});
it('falls back to a base64 data URL when response carries b64_json', async () => {
@@ -264,7 +306,7 @@ describe('XAIImageProvider.generate success path', () => {
const result = await withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
);
@@ -279,13 +321,117 @@ describe('XAIImageProvider.generate success path', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
),
).rejects.toThrow(/Failed to extract image URL/);
// Failure path must NOT meter usage.
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
// ── Image-to-image editing (input_images) ───────────────────────────
describe('XAIImageProvider.generate input_images (edit endpoint)', () => {
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
const editResponse = { data: [{ url: 'https://x.ai/img/edited' }] };
it('routes input_images to POST /v1/images/edits (not generate) with a single image object', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
const result = await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_images: [PNG],
}),
);
expect(result).toBe('https://x.ai/img/edited');
expect(generateMock).not.toHaveBeenCalled();
expect(postMock).toHaveBeenCalledTimes(1);
const [path, opts] = postMock.mock.calls[0]!;
expect(path).toBe('/images/edits');
const body = (opts as { body: Record<string, unknown> }).body;
expect(body.model).toBe('grok-imagine-image');
// Single image → object, not an array.
expect(body.image).toEqual({ type: 'image_url', url: PNG });
});
it('sends an array of image objects for multi-image edits and caps at 3', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'merge them',
input_images: [PNG, PNG, PNG, PNG], // 4 → capped to 3
}),
);
const body = (
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
).body;
expect(Array.isArray(body.image)).toBe(true);
expect(body.image).toHaveLength(3);
});
it('meters output + media_input per input image on edits', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_images: [PNG, PNG],
}),
);
const grok = XAI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'grok-imagine-image',
)!;
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const types = (entries as Array<{ usageType: string }>).map(
(e) => e.usageType,
);
expect(types).toEqual(
expect.arrayContaining([
'xai:grok-imagine-image:output:1k',
'xai:grok-imagine-image:media_input',
]),
);
const media = (
entries as Array<{
usageType: string;
usageAmount: number;
costOverride: number;
}>
).find((e) => e.usageType.endsWith(':media_input'))!;
expect(media.usageAmount).toBe(2);
expect(media.costOverride).toBe(grok.costs.media_input * 2 * 1_000_000);
});
it('folds singular input_image into the edit path', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_image: PNG,
}),
);
expect(postMock).toHaveBeenCalledTimes(1);
const body = (
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
).body;
expect(body.image).toEqual({ type: 'image_url', url: PNG });
});
});
@@ -28,8 +28,13 @@ import type {
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
import { HttpError } from '../../../../core/http/HttpError.js';
const DEFAULT_MODEL = 'grok-2-image';
const PRICE_KEY = 'output';
const DEFAULT_MODEL = 'grok-imagine-image';
// xAI's Grok Imagine edit endpoint accepts up to 3 source images per request.
const MAX_INPUT_IMAGES = 3;
interface XaiImageResponse {
data?: Array<{ url?: string; b64_json?: string }>;
}
export class XAIImageProvider implements IImageProvider {
#client: OpenAI;
@@ -56,8 +61,9 @@ export class XAIImageProvider implements IImageProvider {
}
async generate(params: IGenerateParams): Promise<string> {
const { prompt, test_mode } = params;
const { model } = params;
const { prompt, test_mode, model, ratio, quality } = params;
let { input_images } = params;
const { input_image, input_image_mime_type } = params;
const selectedModel = this.#getModel(model);
@@ -71,15 +77,33 @@ export class XAIImageProvider implements IImageProvider {
});
}
// Backwards compat: fold singular `input_image` into `input_images`.
if (input_image && (!input_images || input_images.length === 0)) {
input_images = [input_image];
}
// xAI caps edits at 3 source images.
if (input_images && input_images.length > MAX_INPUT_IMAGES) {
input_images = input_images.slice(0, MAX_INPUT_IMAGES);
}
const inputImageCount = input_images?.length ?? 0;
const hasInputImages = inputImageCount > 0;
// xAI uses a `resolution` tier ('1k'/'2k') rather than a pixel size.
const resolution = this.#normalizeResolution(quality);
const aspectRatio = this.#aspectRatio(ratio);
const actor = Context.get('actor');
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const priceInCents = selectedModel.costs[PRICE_KEY];
const costInMicroCents = priceInCents * 1_000_000;
const outputPriceInCents = selectedModel.costs[`output:${resolution}`];
const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0;
const estimatedCostInCents =
outputPriceInCents +
(hasInputImages ? mediaInputPriceInCents * inputImageCount : 0);
const usageAllowed = await this.#meteringService.hasEnoughCredits(
actor,
costInMicroCents,
estimatedCostInCents * 1_000_000,
);
if (!usageAllowed) {
@@ -90,15 +114,27 @@ export class XAIImageProvider implements IImageProvider {
);
}
const response = await this.#client.images.generate({
model: selectedModel.id,
prompt,
user: userIdentifier,
});
const response = hasInputImages
? await this.#edit(
selectedModel.id,
prompt,
input_images!,
input_image_mime_type,
resolution,
aspectRatio,
)
: ((await this.#client.images.generate({
model: selectedModel.id,
prompt,
user: userIdentifier,
// xAI-specific params not in the OpenAI type; passed through.
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
resolution,
} as Parameters<
OpenAI['images']['generate']
>[0])) as XaiImageResponse);
const first = response.data?.[0] as
| { url?: string; b64_json?: string }
| undefined;
const first = response.data?.[0];
const url =
first?.url ||
(first?.b64_json
@@ -109,16 +145,75 @@ export class XAIImageProvider implements IImageProvider {
throw new Error('Failed to extract image URL from xAI response');
}
this.#meteringService.incrementUsage(
actor,
`xai:${selectedModel.id}:${PRICE_KEY}`,
1,
costInMicroCents,
);
const usageEntries = [
{
usageType: `xai:${selectedModel.id}:output:${resolution}`,
usageAmount: 1,
costOverride: outputPriceInCents * 1_000_000,
},
];
if (hasInputImages && mediaInputPriceInCents > 0) {
usageEntries.push({
usageType: `xai:${selectedModel.id}:media_input`,
usageAmount: inputImageCount,
costOverride:
mediaInputPriceInCents * inputImageCount * 1_000_000,
});
}
this.#meteringService.batchIncrementUsages(actor, usageEntries);
return url;
}
// Edits go to POST /v1/images/edits as application/json (the OpenAI SDK's
// images.edit() can't be used — it sends multipart/form-data, which xAI
// rejects). We reuse the SDK client's auth + baseURL via its low-level
// post(). Input images are passed as `{ type: 'image_url', url }` objects;
// a single object for one image, an array for multiple.
async #edit(
modelId: string,
prompt: string,
inputImages: string[],
mimeHint: string | undefined,
resolution: string,
aspectRatio: string | undefined,
): Promise<XaiImageResponse> {
const refs = inputImages.map((img) => this.#toImageRef(img, mimeHint));
const body: Record<string, unknown> = {
model: modelId,
prompt,
image: refs.length === 1 ? refs[0] : refs,
resolution,
};
if (aspectRatio) body.aspect_ratio = aspectRatio;
return (await this.#client.post('/images/edits', {
body,
})) as XaiImageResponse;
}
// xAI accepts a public URL or a base64 data URI for input images.
#toImageRef(img: string, mimeHint?: string) {
const url =
img.startsWith('http://') ||
img.startsWith('https://') ||
img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
return { type: 'image_url', url };
}
#normalizeResolution(quality?: string): '1k' | '2k' {
return (quality ?? '').toLowerCase() === '2k' ? '2k' : '1k';
}
#aspectRatio(ratio?: { w: number; h: number }): string | undefined {
if (!ratio || !ratio.w || !ratio.h) return undefined;
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b);
const d = gcd(ratio.w, ratio.h) || 1;
return `${ratio.w / d}:${ratio.h / d}`;
}
#getModel(model?: string) {
const models = this.models();
const found = models.find(
@@ -19,17 +19,43 @@
import type { IImageModel } from '../../types.js';
// Costs are in usd-cents (1 = $0.01). xAI's "Grok Imagine" image API bills a
// per-image output rate by resolution tier (1k/2k) plus, for edits, a
// per-input-image "media input" rate. Rates per the xAI Imagine pricing table:
// https://docs.x.ai/developers/pricing
// grok-imagine-image media $0.002 | 1k $0.02 | 2k $0.02
// grok-imagine-image-quality media $0.01 | 1k $0.05 | 2k $0.07
export const XAI_IMAGE_GENERATION_MODELS: IImageModel[] = [
{
puterId: 'x-ai:x-ai/grok-2-image',
id: 'grok-2-image',
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-2-image'],
name: 'Grok 2 Image',
puterId: 'x-ai:x-ai/grok-imagine-image',
id: 'grok-imagine-image',
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-imagine-image'],
name: 'Grok Imagine Image',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: 'output',
pricing_unit: 'per-image',
index_cost_key: 'output:1k',
costs: {
output: 7, // $0.07 per image
'output:1k': 2, // $0.02 per image
'output:2k': 2, // $0.02 per image
media_input: 0.2, // $0.002 per input image (edits)
},
allowedQualityLevels: ['1k', '2k'],
},
{
puterId: 'x-ai:x-ai/grok-imagine-image-quality',
id: 'grok-imagine-image-quality',
aliases: ['x-ai/grok-imagine-image-quality'],
name: 'Grok Imagine Image (Quality)',
version: '1.0',
costs_currency: 'usd-cents',
pricing_unit: 'per-image',
index_cost_key: 'output:1k',
costs: {
'output:1k': 5, // $0.05 per image
'output:2k': 7, // $0.07 per image
media_input: 1, // $0.01 per input image (edits)
},
allowedQualityLevels: ['1k', '2k'],
},
];
+9 -1
View File
@@ -1117,7 +1117,15 @@ export class AppDriver extends PuterDriver {
const subdomain = this.#extractPuterHostedSubdomain(indexUrl);
if (!subdomain) return;
const row = await this.stores.subdomain.getBySubdomain(subdomain);
let row = await this.stores.subdomain.getBySubdomain(subdomain);
if (!row) {
// Deploys create the subdomain and immediately point the app
// at it, so a replica or peer-cache miss here would wrongly
// refuse the owner. Confirm against the primary before failing.
row = await this.stores.subdomain.getBySubdomain(subdomain, {
primary: true,
});
}
if (!row || row.user_id !== user.id) {
throw new HttpError(400, 'Subdomain not owned by user', {
legacyCode: 'subdomain_not_owned',
@@ -1081,3 +1081,98 @@ describe('AppDriver alias-group index_url merge', () => {
expect(first.uid).not.toBe(second.uid);
});
});
// ── hosted-subdomain ownership check ────────────────────────────────
//
// `#ensurePuterSiteSubdomainIsOwned` gates puter-hosted index_urls on a
// subdomain row the caller owns. Deploy flows create that row and point
// the app at it in back-to-back requests, so the check must tolerate a
// replica/cache miss by confirming against the primary before refusing.
describe('AppDriver hosted-subdomain ownership check', () => {
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
it('accepts a hosted index_url when the subdomain row has not reached the replica yet', async () => {
const { actor, userId } = await makeUser();
const sub = uniqueName('deploy');
await server.stores.subdomain.create({ userId, subdomain: sub });
// Simulate a peer node with a lagging replica: no cache entry for
// the row, and replica reads (`read`) that don't see it yet while
// primary reads (`pread`) do. Sqlite's `pread` delegates to
// `this.read`, so pin it to the original before stubbing `read`.
await server.clients.redis.del(`subdomains:name:${sub}`);
const db = server.clients.db as unknown as {
read: (q: string, p?: unknown[]) => Promise<unknown[]>;
pread: (q: string, p?: unknown[]) => Promise<unknown[]>;
};
const originalRead = db.read.bind(server.clients.db);
const hadOwnPread = Object.prototype.hasOwnProperty.call(db, 'pread');
db.pread = async (query: string, params?: unknown[]) =>
originalRead(query, params);
db.read = async (query: string, params?: unknown[]) =>
query.includes('FROM `subdomains`') && (params ?? []).includes(sub)
? []
: originalRead(query, params);
try {
const result = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'Deployed App',
index_url: hostedUrl(sub),
},
}),
);
expect(result.uid).toEqual(expect.any(String));
// The primary hit must also heal the stale cache: a normal
// lookup now resolves from cache even though the replica
// still misses.
const healed =
await server.stores.subdomain.getBySubdomain(sub);
expect(healed?.subdomain).toBe(sub);
} finally {
delete (db as { read?: unknown }).read;
if (!hadOwnPread) delete (db as { pread?: unknown }).pread;
}
});
it('rejects a hosted index_url whose subdomain does not exist anywhere', async () => {
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'x',
index_url: hostedUrl(uniqueName('ghost')),
},
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it("rejects a hosted index_url pointing at another user's subdomain", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const sub = uniqueName('theirs');
await server.stores.subdomain.create({
userId: owner.userId,
subdomain: sub,
});
await expect(
withActor(intruder.actor, () =>
driver.create({
object: {
name: uniqueName('app'),
title: 'x',
index_url: hostedUrl(sub),
},
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
@@ -244,9 +244,11 @@ export class SubdomainDriver extends PuterDriver {
if (object.domain !== undefined)
patch.domain = object.domain != null ? String(object.domain) : null;
const updated = await this.stores.subdomain.update(row.uuid, patch, {
userId: row.user_id,
});
const updated = await this.stores.subdomain.update(
String(row.uuid),
patch,
{ userId: row.user_id as number },
);
const [shaped] = await this.#hydrateRows(
updated ? [updated as Record<string, unknown>] : [],
);
@@ -351,8 +353,8 @@ export class SubdomainDriver extends PuterDriver {
}
await this.#checkWriteAccess(row, actor);
await this.stores.subdomain.deleteByUuid(row.uuid, {
userId: row.user_id,
await this.stores.subdomain.deleteByUuid(String(row.uuid), {
userId: row.user_id as number,
});
try {
@@ -292,6 +292,22 @@ describe('loadFileInput FS path', () => {
expect(result.fsEntry?.path).toBe(path);
});
it('returns empty bytes for an empty file with no backing S3 object', async () => {
// Files created via touch have size 0 and a null bucket — no S3
// object exists. loadFileInput must return empty content rather than
// throwing NoSuchKey from getObjectStream.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/empty.txt`;
const entry = await withActor(actor, () =>
server.services.fs.touch(userId, { path }),
);
const result = await callLoadFileInput(actor, path);
expect(result.buffer.byteLength).toBe(0);
expect(result.fsEntry?.uuid).toBe(entry.uuid);
});
it('also accepts a `{ uuid }` object reference', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
+18
View File
@@ -22,6 +22,7 @@ import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { FSService } from '../../services/fs/FSService.js';
import { expandTildePath, resolveNode } from '../../services/fs/resolveNode.js';
import { hasNoBackingS3Object } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../stores/fs/S3ObjectStore.js';
import { mimeFromName } from '../../util/fileSigning.js';
@@ -175,6 +176,23 @@ export async function loadFileInput(
// `path`/`uid`/`uuid` (e.g. AI chat `puter_path` content parts) could
// exfiltrate any user's file. Must run before the S3 read below.
await fsService.checkFSAccess(entry, actor, 'read');
// Empty files (created via `touch`) have no backing S3 object —
// getObjectStream would throw NoSuchKey, so return empty content.
if (hasNoBackingS3Object(entry)) {
return {
buffer: Buffer.alloc(0),
filename: entry.name,
mimeType: mimeFromName(entry.name) ?? 'application/octet-stream',
fsEntry: {
uuid: entry.uuid,
path: entry.path,
bucket: entry.bucket,
bucketRegion: entry.bucketRegion,
size: entry.size,
sqlId: entry.id,
},
};
}
const objectKey = entry.uuid;
const { body, contentType, contentLength } =
await stores.s3Object.getObjectStream(
+43 -13
View File
@@ -25,6 +25,7 @@ import { Context } from '../../core/context.js';
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
import { PuterDriver } from '../types.js';
import { loadFileInput } from '../util/fileInput.js';
@@ -33,6 +34,7 @@ const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
const MAX_WORKERS_PER_USER = 100;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.';
let USE_LOCAL_WORKERD = false;
// -- Preamble --------------------------------------------------------
//
@@ -66,6 +68,16 @@ try {
preambleError = true;
}
/**
* The puter.js/router preamble prepended to every worker's source before
* deploy. Exposed so the local-workerd path (`LocalWorkerService`) can build
* the same `preamble + sourceCode` script when it lazily re-deploys a worker
* into Miniflare after a server restart.
*/
export function getWorkerPreamble(): string {
return preamble;
}
/**
* Driver exposing the `workers` interface — Cloudflare Workers
* deployment, lifecycle, and file-path queries.
@@ -100,6 +112,8 @@ export class WorkerDriver extends PuterDriver {
'[workers] preamble not build but workers configured to be enabled. Halting start',
);
}
} else if (cfg.localServer) {
USE_LOCAL_WORKERD = true;
}
this.#subscribeHotReload();
}
@@ -165,7 +179,8 @@ export class WorkerDriver extends PuterDriver {
{ legacyCode: 'forbidden' },
);
}
appOwnerId = actor.app?.id;
const ownerApp = await this.stores.app.getByUid(appId);
appOwnerId = ownerApp?.id ?? actor.app?.id;
authorization = await this.services.auth.createWorkerAppToken(
actor,
appId,
@@ -248,6 +263,13 @@ export class WorkerDriver extends PuterDriver {
});
}
if (appId) {
await this.services.fs.mkdir(actor.user.id!, {
path: `/${actor.user.username}/AppData/${appId}`,
createMissingParents: true,
});
}
// Deploy to Cloudflare
const cfResult = await this.#cfDeploy(
workerName,
@@ -292,7 +314,7 @@ export class WorkerDriver extends PuterDriver {
const actor = this.#requireActor();
const workerName = args.workerName as string | undefined;
let rows: Array<Record<string, unknown>>;
let rows: SubdomainRow[];
if (typeof workerName === 'string' && workerName.length > 0) {
const sub = await this.stores.subdomain.getBySubdomain(
`${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
@@ -357,6 +379,13 @@ export class WorkerDriver extends PuterDriver {
authorization: string,
code: string,
): Promise<Record<string, unknown>> {
if (USE_LOCAL_WORKERD) {
return this.services.localworkerservice.cfDeployLocal(
workerName,
authorization,
code,
);
}
const cfg = this.#workerConfig();
const metadata = JSON.stringify({
body_part: 'swCode',
@@ -422,6 +451,9 @@ export class WorkerDriver extends PuterDriver {
}
async #cfDelete(workerName: string): Promise<Record<string, unknown>> {
if (USE_LOCAL_WORKERD) {
return this.services.localworkerservice.cfDeleteLocal(workerName);
}
const cfg = this.#workerConfig();
const res = await fetch(`${this.#cfBaseUrl}/scripts/${workerName}/`, {
method: 'DELETE',
@@ -447,7 +479,7 @@ export class WorkerDriver extends PuterDriver {
#requireCfConfig(): void {
const cfg = this.#workerConfig();
if (!cfg.XAUTHKEY || !cfg.ACCOUNTID) {
if ((!cfg.XAUTHKEY || !cfg.ACCOUNTID) && !cfg.localServer) {
throw new HttpError(503, 'Cloudflare Workers not configured', {
legacyCode: 'response_timeout',
});
@@ -464,7 +496,7 @@ export class WorkerDriver extends PuterDriver {
}
#checkWorkerWriteAccess(
row: Record<string, unknown>,
row: SubdomainRow,
actor: Actor & { user: { id: number } },
errorStatus: number,
errorMessage: string,
@@ -519,7 +551,7 @@ export class WorkerDriver extends PuterDriver {
// while worker subdomains are keyed to the numeric fsentries.id.
#subscribeHotReload(): void {
if (!this.#cfBaseUrl) return; // CF not configured — skip
if (!this.#cfBaseUrl && !USE_LOCAL_WORKERD) return;
this.clients.event.on(
'fs.write.file',
@@ -707,14 +739,12 @@ export class WorkerDriver extends PuterDriver {
);
}
async #listWorkerRowsForEntry(
entry: FSEntry,
): Promise<Array<Record<string, unknown>>> {
async #listWorkerRowsForEntry(entry: FSEntry): Promise<SubdomainRow[]> {
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
entry.userId,
WORKER_SUBDOMAIN_PREFIX,
);
return workerSubs.filter((r: Record<string, unknown>) => {
return workerSubs.filter((r) => {
return (
String(r.root_dir_id) === String(entry.id) ||
String(r.root_dir_id) === String(entry.uuid) ||
@@ -726,17 +756,17 @@ export class WorkerDriver extends PuterDriver {
async #listWorkerRowsUnderPath(
userId: number,
parentPath: string,
): Promise<Array<Record<string, unknown>>> {
): Promise<SubdomainRow[]> {
const workerSubs = await this.stores.subdomain.listByUserIdAndPrefix(
userId,
WORKER_SUBDOMAIN_PREFIX,
);
const rootDirIds = workerSubs
.map((r: Record<string, unknown>) => r.root_dir_id)
.map((r) => r.root_dir_id)
.filter((id): id is number => typeof id === 'number');
const entriesById =
await this.stores.fsEntry.getEntriesByIds(rootDirIds);
return workerSubs.filter((row: Record<string, unknown>) => {
return workerSubs.filter((row) => {
const rootDirId = row.root_dir_id;
if (typeof rootDirId !== 'number') return false;
const entry = entriesById.get(rootDirId);
@@ -753,7 +783,7 @@ export class WorkerDriver extends PuterDriver {
}
async #deleteWorkerForSourceRow(
row: Record<string, unknown>,
row: SubdomainRow,
userId: number,
): Promise<void> {
const workerFullName = String(row.subdomain ?? '');
+19
View File
@@ -19,6 +19,7 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { isSpanContextValid, trace } from '@opentelemetry/api';
import { puterClients } from './clients';
import { puterControllers } from './controllers';
import { puterDrivers } from './drivers';
@@ -26,6 +27,7 @@ import { PuterServer } from './server';
import { puterServices } from './services';
import { puterStores } from './stores';
import type { IConfig } from './types';
import { installJsonConsole } from './util/jsonConsole.js';
// Config resolution order:
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
@@ -167,6 +169,23 @@ const loadConfig = (): IConfig => {
// if called directly, start the server
if (require.main === module) {
const config = loadConfig();
// Structured logging: when `log_format: "json"`, replace the global console
// so each call emits one JSON line (level, timestamp, msg, and the active
// trace id) — one event per call, so a line-oriented log collector can't
// split stack traces across events. Installed here rather than in the OTel
// preload so it applies even when telemetry is disabled; the trace id is
// simply absent when no span is active.
if (config.log_format === 'json') {
installJsonConsole({
getTraceContext: () => {
const ctx = trace.getActiveSpan()?.spanContext();
if (!ctx || !isSpanContextValid(ctx)) return undefined;
return { traceId: ctx.traceId, spanId: ctx.spanId };
},
});
}
const server = new PuterServer(
config,
puterClients,
+1
View File
@@ -79,6 +79,7 @@
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
+73 -14
View File
@@ -25,7 +25,9 @@ import type { Application, RequestHandler } from 'express';
import helmet from 'helmet';
import uaParser from 'ua-parser-js';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import http from 'node:http';
import { puterClients } from './clients';
import { puterControllers } from './controllers';
@@ -60,6 +62,7 @@ import {
createUserSubdomainRedirect,
createNativeAppStatic,
} from './core/http/middleware/hostRedirects';
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
import { PuterRouter } from './core/http/PuterRouter';
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
@@ -452,6 +455,18 @@ export class PuterServer {
res.sendStatus(200);
});
// -- Local Worker proxy (*.workers.puter.localhost) ----------
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
// Mounted BEFORE body parsing so the Worker receives the raw request
// stream; no-op in production (real Cloudflare via WorkerDriver).
this.#app.use(
createLocalWorkerProxyMiddleware(this.#config, {
clients: this.clients,
stores: this.stores,
services: this.services,
}),
);
// -- Body parsing (JSON + text-as-json shim) -----------------
const captureRawBody: NonNullable<
Parameters<typeof express.json>[0]
@@ -481,7 +496,6 @@ export class PuterServer {
createAuthProbe({
authService,
cookieName: this.#config.cookie_name,
kvStore: this.stores.kv,
}),
);
}
@@ -729,11 +743,13 @@ export class PuterServer {
if (status < 500) return;
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
}
const signature = isHttp
? err.legacyCode || err.code || err.message
: err instanceof Error
? err.message
: String(err);
const signature = !isHttp
? err instanceof Error
? err.message
: String(err)
: status >= 500
? `${err.legacyCode || err.code || 'http'}:${err.message}`
: err.legacyCode || err.code || err.message;
const routePath =
(req as unknown as { route?: { path?: string } }).route
?.path ?? req.path;
@@ -862,10 +878,12 @@ export class PuterServer {
// carry, so it works for either actor shape.
//
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
// should be callable from scripts/automation using an admin's access
// token, not only from browser sessions. `adminOnlyGate` gates on
// `actor.user.username`, which is populated for access-token and
// app-under-user actors alike.
// stay callable from scripts/automation using an admin's full-access
// token, not only from browser sessions — both are root tokens.
// Beyond the username check, `adminOnlyGate` requires a root token
// (rejecting an admin acting through a third-party app) unless the
// route is also appId-gated, in which case `allowedAppIdsGate` governs
// which apps may pass.
if (opts.requireUserActor) {
mwChain.push(
requireUserActorGate({
@@ -876,7 +894,11 @@ export class PuterServer {
if (opts.adminOnly) {
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
mwChain.push(adminOnlyGate(extras));
mwChain.push(
adminOnlyGate(extras, {
appGated: Boolean(opts.allowedAppIds),
}),
);
}
if (opts.allowedAppIds) {
@@ -1144,9 +1166,46 @@ export class PuterServer {
await this.#fireOnServerStart();
console.log('PuterServer has fully booted.');
// Auto-launch the browser on dev boot (matches v1 WebServerService).
// Opt out via `no_browser_launch: true` in config.
if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
// CLI: `--server` (optionally `--puter-backend=<gui-origin>`)
// runs the AuthMe flow against a remote Puter (default
// puter.com), then opens the local GUI already logged in and
// pointed at that backend. Restores the v1 WebServerService
// `--server` behavior; works in any env. When set, it takes
// over browser launch so we don't also open a plain tab.
const { values: cliArgs } = parseArgs({
args: process.argv.slice(2),
options: {
server: { type: 'boolean' },
'puter-backend': { type: 'string' },
},
strict: false,
});
if (cliArgs.server) {
try {
// tools/auth_gui.js is not compiled into dist/, so
// resolve it from the package root (cwd, per the
// `start` script) rather than relative to this module.
const authGuiUrl = pathToFileURL(
path.resolve(process.cwd(), 'tools/auth_gui.js'),
).href;
const authGui = (await import(authGuiUrl)).default;
await authGui(
cliArgs['puter-backend'] as string | undefined,
);
} catch (e) {
console.log(
'[server] could not start AuthMe browser flow:',
(e as Error).message,
);
}
} else if (
this.#config.env === 'dev' &&
!cfg.no_browser_launch
) {
// Auto-launch the browser on dev boot (matches v1
// WebServerService). Opt out via `no_browser_launch: true`.
try {
const openModule = await import('open');
await openModule.default(liveUrl);
@@ -0,0 +1,174 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { AppOriginBlocklistService } from './AppOriginBlocklistService.js';
type Row = {
domain: string;
include_subdomains: number;
reason?: string | null;
};
const makeService = (
rows: Row[],
read?: () => Promise<unknown>,
): { service: AppOriginBlocklistService; reads: { count: number } } => {
const reads = { count: 0 };
const db = {
read:
read ??
(async () => {
reads.count++;
return rows;
}),
};
const service = new AppOriginBlocklistService(
{} as never,
{ db } as never,
{} as never,
{} as never,
);
return { service, reads };
};
describe('AppOriginBlocklistService', () => {
describe('isHostBlocked — exact entries', () => {
it('matches the exact host only', async () => {
const { service } = makeService([
{ domain: 'some.evil.com', include_subdomains: 0 },
]);
expect(await service.isHostBlocked('some.evil.com')).toEqual({
blocked: true,
reason: undefined,
});
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
false,
);
expect(
(await service.isHostBlocked('x.some.evil.com')).blocked,
).toBe(false);
});
});
describe('isHostBlocked — include_subdomains entries', () => {
it('matches the apex and any subdomain, but not lookalikes', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1, reason: 'abuse' },
]);
expect(await service.isHostBlocked('evil.com')).toEqual({
blocked: true,
reason: 'abuse',
});
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
true,
);
expect((await service.isHostBlocked('a.b.evil.com')).blocked).toBe(
true,
);
// Suffix-but-not-subdomain must NOT match.
expect((await service.isHostBlocked('notevil.com')).blocked).toBe(
false,
);
expect((await service.isHostBlocked('evil.com.org')).blocked).toBe(
false,
);
});
});
describe('normalization', () => {
it('lowercases, strips port, and ignores empty input', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1 },
]);
expect((await service.isHostBlocked('A.EVIL.COM')).blocked).toBe(
true,
);
expect(
(await service.isHostBlocked('a.evil.com:8080')).blocked,
).toBe(true);
expect((await service.isHostBlocked('')).blocked).toBe(false);
});
it('normalizes stored entries too (uppercase/leading dot)', async () => {
const { service } = makeService([
{ domain: '.Evil.COM', include_subdomains: 1 },
]);
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
true,
);
});
});
describe('isOriginBlocked', () => {
it('extracts the host from a full URL', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1 },
]);
expect(
(await service.isOriginBlocked('https://app.evil.com/path?q=1'))
.blocked,
).toBe(true);
expect(
(await service.isOriginBlocked('https://good.com/')).blocked,
).toBe(false);
});
it('accepts a scheme-less origin', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
expect((await service.isOriginBlocked('evil.com')).blocked).toBe(
true,
);
});
});
describe('caching', () => {
it('reuses the cached snapshot within the TTL', async () => {
const { service, reads } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
await service.isHostBlocked('evil.com');
await service.isHostBlocked('evil.com');
expect(reads.count).toBe(1);
});
it('reloads after invalidate()', async () => {
const { service, reads } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
await service.isHostBlocked('evil.com');
service.invalidate();
await service.isHostBlocked('evil.com');
expect(reads.count).toBe(2);
});
});
describe('resilience', () => {
it('fails open (not blocked) when the DB read throws', async () => {
const { service } = makeService([], async () => {
throw new Error('no such table: blocked_app_origins');
});
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
false,
);
});
});
});
@@ -0,0 +1,172 @@
/**
* 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 { PuterService } from '../types.js';
export interface BlockMatch {
blocked: boolean;
reason?: string;
}
interface BlocklistEntry {
domain: string;
includeSubdomains: boolean;
reason: string | null;
}
const NOT_BLOCKED: BlockMatch = { blocked: false };
/**
* In-memory, TTL-cached view of the `blocked_app_origins` table.
*
* Admins manage the table (the admin extension writes it directly, mirroring
* how it writes the `user` table for suspend). This service answers the hot
* "is this app/origin blocked?" question from a cached snapshot so the
* per-request app-token validation path never hits the DB.
*
* Consistency: the cache refreshes lazily after {@link CACHE_TTL_MS}. Because
* each worker process holds its own cache, a freshly-added block can take up
* to one TTL to take effect across the fleet — acceptable for an
* admin-initiated block.
*/
export class AppOriginBlocklistService extends PuterService {
private static readonly CACHE_TTL_MS = 30_000;
#entries: BlocklistEntry[] = [];
#loadedAt = 0;
#inflight: Promise<void> | null = null;
/**
* Decide whether a bare host (already without scheme/path) is blocked.
* Exact entries match the host verbatim; `include_subdomains` entries
* also match any subdomain of `domain`.
*/
async isHostBlocked(host: string): Promise<BlockMatch> {
const normalized = normalizeHost(host);
if (!normalized) return NOT_BLOCKED;
await this.#ensureFresh();
for (const entry of this.#entries) {
const matches = entry.includeSubdomains
? normalized === entry.domain ||
normalized.endsWith(`.${entry.domain}`)
: normalized === entry.domain;
if (matches) {
return {
blocked: true,
reason: entry.reason ?? undefined,
};
}
}
return NOT_BLOCKED;
}
/**
* Decide whether an origin/URL is blocked by extracting its host. Accepts
* full URLs (`https://app.example.com/path`) and bare hosts alike.
*/
async isOriginBlocked(origin: string): Promise<BlockMatch> {
return this.isHostBlocked(hostFromOrigin(origin));
}
/** Drop the cached snapshot so the next query reloads from the DB. */
invalidate(): void {
this.#loadedAt = 0;
}
async #ensureFresh(): Promise<void> {
const age = Date.now() - this.#loadedAt;
if (
this.#loadedAt !== 0 &&
age < AppOriginBlocklistService.CACHE_TTL_MS
) {
return;
}
// Single-flight: concurrent callers share one reload.
if (!this.#inflight) {
this.#inflight = this.#reload().finally(() => {
this.#inflight = null;
});
}
await this.#inflight;
}
async #reload(): Promise<void> {
try {
const rows = (await this.clients.db.read(
'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`',
)) as Array<Record<string, unknown>>;
this.#entries = rows
.map((row) => {
const domain = normalizeHost(String(row.domain ?? ''));
if (!domain) return null;
return {
domain,
includeSubdomains: Boolean(
Number(row.include_subdomains ?? 0),
),
reason: row.reason == null ? null : String(row.reason),
} satisfies BlocklistEntry;
})
.filter((e): e is BlocklistEntry => e !== null);
this.#loadedAt = Date.now();
} catch (e) {
// Never let a transient DB error turn into a request-blocking
// throw on the auth hot path. Keep serving the previous snapshot;
// a missing table (fresh dev DB pre-migration) yields an empty
// blocklist, which is the safe-open default.
console.warn(
'[app-origin-blocklist] reload failed:',
(e as Error)?.message ?? e,
);
if (this.#loadedAt === 0) {
this.#entries = [];
this.#loadedAt = Date.now();
}
}
}
}
/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */
const normalizeHost = (host: string): string => {
let h = (host ?? '').trim().toLowerCase();
if (!h) return '';
h = h.replace(/^\./, '');
// Strip a trailing :port (IPv6 literals are not app origins, so the
// simple split is safe here).
const colon = h.indexOf(':');
if (colon !== -1) h = h.slice(0, colon);
return h;
};
/** Extract the host from a full URL, falling back to treating input as a host. */
const hostFromOrigin = (origin: string): string => {
const raw = (origin ?? '').trim();
if (!raw) return '';
try {
return new URL(raw).hostname;
} catch {
// Not a parseable URL — maybe a scheme-less origin or bare host.
try {
return new URL(`https://${raw}`).hostname;
} catch {
return raw;
}
}
};
@@ -25,21 +25,27 @@ import { PuterService } from '../types.js';
* Resolved at call time against the apps table.
*/
const RECOMMENDED_APP_NAMES = [
'app-center',
'builder',
'editor',
'camera',
'recorder',
'app-center',
'dev-center',
'butler',
'editor',
'calculator',
'calendar',
'code',
'camera',
'music-player',
'recorder',
'contacts',
'browser',
'memos',
'audio-editor',
'ai-image-project',
'word-processor',
'spreadsheet',
'presentation',
'pdf-editor',
'vault',
'basketball-tap',
'blockup',
'pretty-tiles',
@@ -72,5 +78,8 @@ function toAppSummary(
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// An app with no owner isn't owned by a Puter user — it's an
// "external" (origin-bootstrapped) app.
external: app.owner_user_id == null || app.owner_user_id === '',
};
}
@@ -1348,6 +1348,85 @@ describe('AuthService (integration)', () => {
});
});
describe('app origin blocklist enforcement', () => {
// The blocklist service caches with a TTL, so seed the row then
// invalidate the in-memory snapshot to force a reload for the test.
const blockOrigin = async (
domain: string,
includeSubdomains = false,
) => {
await server.clients.db.write(
'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`) VALUES (?, ?)',
[domain, includeSubdomains ? 1 : 0],
);
(
server.services.appOriginBlocklist as {
invalidate: () => void;
}
).invalidate();
};
it('appUidFromOrigin throws 403 app_blocked for a blocked exact host', async () => {
const host = `blocked-${uuidv4()}.example.com`;
await blockOrigin(host);
await expect(
authService.appUidFromOrigin(`https://${host}/`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_blocked',
});
});
it('appUidFromOrigin throws for a subdomain of an include_subdomains entry', async () => {
const apex = `evil-${uuidv4()}.example.com`;
await blockOrigin(apex, true);
await expect(
authService.appUidFromOrigin(`https://app.${apex}/`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_blocked',
});
});
it('appUidFromOrigin still resolves an unrelated origin', async () => {
const uid = await authService.appUidFromOrigin(
`https://fine-${uuidv4()}.example.com/`,
);
expect(uid).toMatch(/^app-/);
});
it('rejects an already-issued app token once its origin is blocked', async () => {
const user = await makeUser();
const host = `late-block-${uuidv4()}.example.com`;
const appUid = `app-${uuidv4()}`;
// App row carries the to-be-blocked host as its index_url.
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1],
);
const appToken = await authService.getUserAppToken(
{
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
},
} as Actor,
appUid,
);
// Before blocking the token authenticates normally.
const ok = await authService.authenticate(appToken);
expect(ok.actor?.app?.uid).toBe(appUid);
// After blocking the same token is rejected with the blocked signal.
await blockOrigin(host);
const blocked = await authService.authenticate(appToken);
expect(blocked.actor).toBeUndefined();
expect(blocked.blocked).toBeTruthy();
});
});
describe('getUserAppToken', () => {
it('throws 403 when actor has no user', async () => {
await expect(
+43 -2
View File
@@ -28,6 +28,7 @@ import {
import type { UserRow } from '../../stores/user/UserStore';
import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { Span } from '../../util/span.js';
import type { puterServices } from '../index';
import { PuterService } from '../types';
import { FULL_API_ACCESS } from '../permission/consts';
@@ -50,6 +51,13 @@ export interface AuthResult {
actor?: Actor;
reauth?: { reason: ReauthReason; auth_id?: string };
invalid?: true;
/**
* The token authenticated, but its app is on the origin blocklist. The
* auth probe surfaces this as `req.appBlocked`; gates translate it to a
* 403 `app_blocked`. Distinct from `invalid` so the client sees a clear
* "app blocked" error rather than a generic auth failure.
*/
blocked?: { reason?: string };
}
/**
@@ -136,6 +144,7 @@ export class AuthService extends PuterService {
return { authId: decoded.auth_id };
}
@Span('auth.authenticate')
async authenticate(
token: string,
ctx: { ip?: string; userAgent?: string } = {},
@@ -891,6 +900,19 @@ export class AuthService extends PuterService {
const event = { origin: aliased };
await this.clients.event?.emitAndWait('app.from-origin', event, {});
// Blocked origins can't acquire an app token (or have one minted /
// checked / granted), so the app loses every path to Puter resources.
const block = await this.services.appOriginBlocklist.isOriginBlocked(
event.origin,
);
if (block.blocked) {
throw new HttpError(
403,
'This app is not allowed to access Puter resources',
{ legacyCode: 'app_blocked' },
);
}
const canonicalUid = await this.#findCanonicalAppUidForOrigin(
event.origin,
);
@@ -1784,6 +1806,21 @@ export class AuthService extends PuterService {
const app = await this.stores.app.getByUid(decoded.app_uid);
if (!app) return { invalid: true };
// Reject already-issued app tokens whose app origin is now blocked, so
// a block takes effect immediately rather than waiting for token
// expiry. The app's `index_url` host is the same origin checked at
// token acquisition.
const indexUrl = (app as { index_url?: unknown }).index_url;
if (typeof indexUrl === 'string' && indexUrl) {
const block =
await this.services.appOriginBlocklist.isOriginBlocked(
indexUrl,
);
if (block.blocked) {
return { blocked: { reason: block.reason } };
}
}
let rawRow: SessionRow | null = null;
if (decoded.session_uid) {
rawRow = (await this.stores.session.getByUuidAny(
@@ -1930,7 +1967,9 @@ export class AuthService extends PuterService {
#buildUserActor(user: UserRow, session: SessionRow | null): Actor {
return {
user: this.#actorUserFromRow(user),
session: session ? { uid: session.uuid } : null,
session: session
? { uid: session.uuid, kind: session.kind ?? null }
: null,
};
}
@@ -1945,7 +1984,9 @@ export class AuthService extends PuterService {
uid: app.uid,
id: app.id,
},
session: session ? { uid: session.uuid } : null,
session: session
? { uid: session.uuid, kind: session.kind ?? null }
: null,
};
}
}
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more