Compare commits

..
98 Commits
Author SHA1 Message Date
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
Daniel Salazar 0405de30bd update taskbar apps (#3286) 2026-06-22 14:26:35 -07:00
Nariman Jelveh 40c2799b06 Add 'builder' to recommended apps list
Include 'builder' in the RECOMMENDED_APP_NAMES array in src/backend/services/apps/RecommendedAppsService.ts so the RecommendedAppsService treats the builder app as a recommended application and surfaces it accordingly.
2026-06-22 14:22:27 -07:00
Daniel Salazar 4bcdeefe68 chore: bump up deps (#3285) 2026-06-22 13:58:45 -07:00
Daniel Salazar b6ba6dd90e fix: [PUT-1199] more prelude signals (#3282) 2026-06-22 08:16:59 -07:00
Daniel Salazar 954f8b7b8d fix: puter self host guides + default bug (#3281)
* fix: oss dev center html_encode hardening

* fix: puter self host guides + default bug
2026-06-21 22:32:43 -07:00
Neal Shah 24d700673c Compaction support for OpenAI and Anthropic (#3279)
* Alpha: compaction support for OpenAI and Anthropic

* update lock

* fix billing for anthropic compactions

* Fix max_tokens bug in together provider

* Fix responses compaction
2026-06-21 21:57:56 -04:00
c6d64029e0 feat: meter Gemini thinking tokens and grounding requests (#3178)
* feat: meter Gemini thinking tokens and grounding requests

- Thinking tokens: Extracted from standard completion tokens to ensure they are billed accurately at the correct model-specific rate.
- Grounding requests: Added flat-fee metering for Google Search by tracking grounding_metadata across both streaming and non-streaming responses.
- Pricing updates: Corrected stale rates for Gemini 2.5 Flash output, cached tokens, thinking tokens, and grounding requests.

* fix: correct Gemini 2.x metering rates and harden grounding capture

Pricing corrections (verified against ai.google.dev/gemini-api/docs/pricing):
- gemini-2.5-flash output is $2.50/M, not $1.00/M: restore
  completion_tokens to 250 and bill thinking_tokens at the same output
  rate (250). The previous 100 under-billed output ~60%, and this is the
  provider's default model.
- gemini-2.5-flash cache read is $0.03/M: restore cached_tokens to 3
  (the 7.5 value over-billed).
- Grounding with Google Search is $35 / 1,000 requests for Gemini 2.x
  models and $14 / 1,000 for 3.x. Set grounding_requests to 3_500_000
  for gemini-2.0-flash, gemini-2.5-flash, gemini-2.5-flash-lite and
  gemini-2.5-pro; 3.x models keep 1_400_000.

Streaming robustness:
- In create_chat_stream_handler, don't let a later extra_content chunk
  without grounding_metadata overwrite an earlier one that carried it,
  so grounding requests are still metered.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-20 15:08:38 -07:00
jelveh 40ff866598 Add 'butler' to recommended apps
Include 'butler' in the RECOMMENDED_APP_NAMES array in RecommendedAppsService so it appears in the recommended apps list. Updates src/backend/services/apps/RecommendedAppsService.ts.
2026-06-19 15:50:57 -07:00
Nariman Jelveh 36888e35d3 Add phone verification UI improvements and helpers (#3274)
Revamps the phone verification dialog and adds reusable phone helper modules.

- Heavily refactors UIWindowPhoneVerificationRequired.js to add a searchable inline country picker, national-number live formatting, country autodetect for "+" input, WebOTP auto-fill support, resend cooldown timer, accessibility improvements, proper teardown/cleanup, and last-used country persistence.
- Introduces helpers/src/helpers/country_codes.js (get_country_list, flag emoji) which builds a localized list of dialable countries from libphonenumber-js and Intl.DisplayNames.
- Introduces helpers/src/helpers/phone.js with thin wrappers around libphonenumber-js: format_phone_as_you_type, inspect_phone, phone_example_for, detect_country_from_input.
- Adds English i18n strings for the new UI in src/gui/src/i18n/translations/en.js.

These changes keep submission canonicalization using libphonenumber-js (E.164), improve UX for selecting/formatting numbers, and make the country list and phone utilities reusable across the app.
2026-06-19 10:28:30 -07:00
Reynaldi Chernando 30432b931a Add PUT and DELETE to preflight allowed methods (#3278) 2026-06-19 12:35:29 -04:00
Reynaldi Chernando e2174a14cf Update router cors preflight docs (#3277) 2026-06-19 20:50:41 +07:00
Daniel Salazar d3cb89d8c9 fix: import in extensions (#3276) 2026-06-18 21:30:00 -07:00
Reynaldi Chernando c8625b3396 Release puterjs npm (#3275)
* Release puterjs npm

* add package lock
2026-06-18 18:26:39 -07:00
Reynaldi Chernando b576f01d12 Sync Puter.js types and docs (#3194)
* AI chat types

* ai voice types

* update tts docs and jsdoc

* stt types and docs

* txt2img docs and types

* txt2vid and img2txt

* apps

* signin

* perms

* fs

* kv

* hosting

* workers

* net

* ui

* jsdoc
2026-06-18 09:34:13 -07:00
Vyasdev a87cafee3c Revise bug report and feature request instructions (#3220)
Updated the wording for reporting bugs and feature requests in Japanese.
2026-06-18 09:27:55 -07:00
Reynaldi Chernando 1c51fde163 Add minimax m3 direct integration + cleanup model list (#3263) 2026-06-18 10:12:44 -04:00
Daniel Salazar b05699db77 fix: AGENTS.md cleanup (#3273) 2026-06-17 17:07:20 -07:00
Daniel Salazar 163e48ab27 feat: viz over user verification types (#3271) 2026-06-17 14:21:55 -07:00
Reynaldi Chernando 0b41d992a4 Add glm 5.2 direct integration (#3269) 2026-06-17 09:40:10 -04:00
Miika Kuisma c2ddfd6867 Merge pull request #3270 from HeyPuter/fix-file-dialog
puter.js - fix issue where content of file dialog wasn't visible
2026-06-17 13:08:43 +03:00
Miika Kuisma 81b9dc1f06 fix issue where content of file dialog wasn't visible 2026-06-17 13:07:13 +03:00
Daniel Salazar 5506ddb2c2 feat: fp cleanup and better devex (#3268) 2026-06-16 22:48:54 -07:00
Neal Shah c74469b01a prelude RCS (#3267) 2026-06-15 19:08:30 -04:00
Daniel Salazar b706693f82 wip: hardening (#3266) 2026-06-15 16:07:44 -07:00
Daniel SalazarandNeal Shah cd800148d5 feat: optional additional verification gates (#3262)
* phone number verificiation initial

* finish phone verification

* feat: add card gate for signups

* chore: npm

* fix: modal order and wording

* fix:wording

---------

Co-authored-by: Neal Shah <[email protected]>
2026-06-15 09:16:00 -07:00
Ricardo Sawir 900b0d7aa6 fix: correct puter container healthcheck URL (#3255)
The healthcheck was using 'puter.localhost:4100/test' which fails for
two reasons:
- 'puter.localhost' does not resolve inside the container; the service
  listens on all interfaces at port 4100, so 'localhost' works
- '/test' endpoint does not exist; use '/' which serves the shell

This caused the container to be perpetually marked as unhealthy.

Fixes #3191
2026-06-13 23:17:44 -07:00
Daniel Salazar 7e21c1f888 fix: fs metadata sanitation (#3257) 2026-06-12 22:56:06 -07:00
Daniel Salazar 00d6f9c230 fix: disallow unverified outlook emails (#3256) 2026-06-12 18:52:24 -07:00
jelveh 4c03080efa Update mcp.md 2026-06-12 16:55:37 +02:00
jelveh 432a11d289 Add Cursor MCP docs and icons 2026-06-12 16:37:53 +02:00
jelveh e9e233404b Add icons for Claude, Codex and OpenCode 2026-06-12 13:20:15 +02:00
jelveh 8101d42604 Capitalize OpenCode in MCP docs 2026-06-12 13:16:41 +02:00
jelveh 88cedd43bc Add opencode MCP example to docs 2026-06-12 13:11:24 +02:00
Daniel Salazar d3a2934d9b feat: fingerprinting for signup checks (#3254) 2026-06-12 00:59:21 -07:00
Daniel Salazar 13a36b6199 fix: secure http calls (#3252) 2026-06-11 13:43:58 -07:00
Daniel Salazar a155464d46 fix: more data for puter site serve event (#3251) 2026-06-10 23:27:13 -07:00
272 changed files with 21009 additions and 6381 deletions

No files matched your search

+2 -2
View File
@@ -64,9 +64,9 @@ 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.
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
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.
**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.
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
+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",
+3
View File
@@ -75,6 +75,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": {
+1 -1
View File
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
+36 -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.
@@ -199,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
```
## Running behind your own reverse proxy
The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
4. **Forward the standard proxy headers.** Puter needs:
- `Host` — the original request host (rule 1).
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
## Step 4 — Bring it up
```bash
@@ -353,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"`.
+1 -1
View File
@@ -264,7 +264,7 @@ services:
# Persistent runtime data (anything your config points at /var/puter).
- ./puter/data/puter:/var/puter:z
healthcheck:
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
interval: 30s
timeout: 3s
retries: 3
+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,
};
}),
);
};
+1 -1
View File
@@ -1,4 +1,3 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { HttpError } from '@heyputer/backend/src/core/http';
import {
@@ -6,6 +5,7 @@ import {
driversContainers,
} from '@heyputer/backend/src/exports';
import { extension } from '@heyputer/backend/src/extensions';
import type { Request, Response } from 'express';
const services = extension.import('service');
const clients = extension.import('client');
+4
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();
+5
View File
@@ -72,10 +72,14 @@ export const handleWhoami = async (
unconfirmed_email: user.email,
email_confirmed: user.email_confirmed || user.username === 'admin',
requires_email_confirmation: user.requires_email_confirmation,
phone: user.phone,
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
desktop_bg_url: user.desktop_bg_url,
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(
@@ -157,6 +161,7 @@ export const handleWhoami = async (
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.is_user_token;
}
if (actor.app) {
+22 -10
View File
@@ -16,11 +16,20 @@
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
#
# Tunable env vars:
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_PROTOCOL public scheme: http | https (default: http)
# Set https when a TLS-terminating reverse proxy (Caddy,
# Traefik, nginx, a cloud LB) sits in front — Puter then
# builds https:// origins, S3 URLs, and redirects. See
# "Running behind your own reverse proxy" in doc/self-hosting.md.
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
# 1 = the bundled nginx (or a single external proxy);
# 2 = two hops (e.g. Cloudflare → your proxy → Puter).
# PUTER_ENV prod | dev (default: prod)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
set -eu
@@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
PUTER_PORT="${PUTER_PORT:-80}"
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
PUTER_ENV="${PUTER_ENV:-prod}"
PUTER_FORCE="${PUTER_FORCE:-0}"
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
@@ -117,9 +129,9 @@ EOF
cat > puter/config/config.json <<EOF
{
"domain": "$PUTER_DOMAIN",
"protocol": "http",
"protocol": "$PUTER_PROTOCOL",
"pub_port": $PUTER_PORT,
"env": "prod",
"env": "$PUTER_ENV",
"static_hosting_domain": "site.$PUTER_DOMAIN",
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
@@ -159,7 +171,7 @@ EOF
"s3": {
"s3Config": {
"endpoint": "http://s3:9000",
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
"accessKeyId": "puter",
"secretAccessKey": "$S3_SECRET_KEY",
"region": "us-east-1",
@@ -173,7 +185,7 @@ EOF
"ollama": { "enabled": false }
},
"trust_proxy": 1
"trust_proxy": $PUTER_TRUST_PROXY
}
EOF
fi
@@ -187,6 +199,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
log "follow puter logs:"
log " cd $PUTER_DIR && docker compose logs -f puter"
log ""
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "first-boot admin password is logged once — grab it with:"
log " cd $PUTER_DIR && docker compose logs puter | grep password"
+5176 -4782
View File
File diff suppressed because it is too large. Load diff
+17 -6
View File
@@ -44,18 +44,18 @@
"yaml": "^2.8.1"
},
"scripts": {
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"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": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run build:ts",
"prestart": "npm run setupExtensions && npm run build:ts",
"dev": "npm start",
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
"postinstall": "node ./tools/extensionSetup.mjs"
"setupExtensions": "node ./tools/extensionSetup.mjs"
},
"workspaces": [
"src/*",
@@ -77,9 +77,20 @@
"ai": "^6.0.73",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.6",
"open": "^10.1.0"
},
"engines": {
"node": ">=24.0.0"
},
"allowScripts": {
"@google/genai": false,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
}
}
@@ -86,6 +86,13 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[50, ['0055_username_nocase_unique.sql']],
[51, ['0056_sessions_kind_worker.sql']],
[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 {
@@ -24,7 +24,9 @@
--
--
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
-- migration files, which CALL it as a one-line idempotent column add. Left
-- resident (not dropped at end of file) so it outlives this migration.
--
DROP PROCEDURE IF EXISTS _puter_add_col;
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_col;
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
-- it as a one-line idempotent column add, and migrations replay on every boot
-- with no applied-state tracking, so the helper must outlive this file. The
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
@@ -0,0 +1,47 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
-- E.164 number collected during verification (indexed like `email`);
-- `requires_phone_verification` gates account use for low-reputation signups
-- (not indexed, mirroring `requires_email_confirmation`).
--
-- Idempotent: column adds use _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', 'phone', '`phone` varchar(20) DEFAULT NULL');
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_phone_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_phone'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_phone_index();
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
@@ -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/>.
-- Credit-card verification column. Mirrors SQLite migration 0059.
-- `requires_card_verification` gates account use for low-reputation signups;
-- the card itself never touches our DB, so this is the only column (not
-- indexed, mirroring `requires_phone_verification`).
--
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
-- directory replays safely.
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
@@ -0,0 +1,47 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
-- is the Stripe card fingerprint (stable per card number) recorded when a user
-- clears card verification — the card sibling of `phone`, indexed like it so
-- admin tooling can find the accounts that verified with a given card. The card
-- itself never touches our DB, only Stripe's fingerprint for it.
--
-- 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', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_card_fingerprint'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_card_fingerprint_index();
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
@@ -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,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/>.
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
-- E.164 number collected during verification (indexed like `email`);
-- `requires_phone_verification` gates account use for low-reputation signups
-- (not indexed, mirroring `requires_email_confirmation`).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
@@ -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/>.
-- Credit-card verification column. Mirrors SQLite migration 0059.
-- `requires_card_verification` gates account use for low-reputation signups;
-- the card itself never touches our DB, so this is the only column (not
-- indexed, mirroring `requires_phone_verification`).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
@@ -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/>.
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
-- is the Stripe card fingerprint (stable per card number) recorded when a user
-- clears card verification — the card sibling of `phone`, indexed like it so
-- admin tooling can find the accounts that verified with a given card. The card
-- itself never touches our DB, only Stripe's fingerprint for it.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
@@ -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,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/>.
-- SMS phone verification. `phone` holds the E.164 number collected during
-- verification (indexed like `email`). `requires_phone_verification` gates
-- account use until verified — the abuse v2 harness sets it for low-reputation
-- signups instead of blocking them. (Not indexed, mirroring
-- `requires_email_confirmation`.)
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
@@ -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/>.
-- Credit-card verification ($0 authorization handled by a payments
-- extension). `requires_card_verification` gates account use until verified —
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
-- them. The card itself never touches our DB, so this is the only column.
-- (Not indexed, mirroring `requires_phone_verification`.)
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
@@ -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/>.
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
-- (stable per card number) recorded when a user clears card verification — the
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
-- accounts that verified with a given card. The card itself never touches our
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
-- allowlist), so it never reaches clients.
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
@@ -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`);
+102
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': {
@@ -131,6 +146,18 @@ export type EventMap = {
data?: unknown;
abuse?: unknown;
trail?: Array<string>;
/**
* Set by the abuse harness for flagged signups — the id under which the
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back on
* the request for log / support correlation.
*/
trail_id?: string;
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: 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. */
requires_card_verification?: boolean;
[key: string]: unknown;
};
'puter.signup.success': {
@@ -139,6 +166,9 @@ export type EventMap = {
email: string;
username: string;
ip?: string | null;
fingerprint?: string | null;
/** True when the created account is a temp user (no email/password). */
is_temp?: boolean;
[key: string]: unknown;
};
'email.validate': {
@@ -158,6 +188,78 @@ export type EventMap = {
user_uid: string;
email: string;
};
// Phone-reuse cap is pure mechanism here — the abuse extension answers
// (emitted via `emitAndWait`) by counting how many OTHER accounts have
// already verified this number and flipping `allowed` to false when the
// cross-account limit is hit. No extension listening → `allowed` stays
// true and the send proceeds.
'puter.phone-verification.check': {
user_id: number;
user_uid: string;
phone: string;
// Client-supplied device fingerprint for this request, or null. Lets
// the abuse extension cap sends per device (across accounts) so a
// device farm can't dodge the per-account cap with fresh signups.
device_fingerprint: string | null;
allowed: boolean;
reason: string | null;
[key: string]: unknown;
};
// Fire-and-forget signal that a code was actually sent — the abuse
// extension bumps its per-number / per-account / per-device send-velocity
// counters off this. No-op with no extension listening.
'puter.phone-verification.sent': {
user_id: number;
user_uid: string;
phone: string;
device_fingerprint: string | null;
};
'user.phone-verified': {
user_id: number;
user_uid: string;
phone: string;
};
// Card verification is pure mechanism here — a payments extension fills
// these in (emitted via `emitAndWait`). `enabled` stays null when no
// extension is installed; the extension always sets it (true/false) so
// the endpoints can distinguish "disabled" from "not installed".
'puter.card-verification.setup': {
user_id: number;
user_uid: string;
ip?: string | null;
// Client-supplied device fingerprint for this request, or null. Lets
// the abuse extension cap card-verification setups per device (across
// accounts) before any Stripe SetupIntent is created.
device_fingerprint: string | null;
enabled: boolean | null;
// Set false by the extension to refuse this setup (e.g. the per-device
// setup-velocity cap); `reason` carries the opaque code. Stays true
// when allowed or with no extension listening.
allowed: boolean;
reason: string | null;
client_secret: string | null;
publishable_key: string | null;
[key: string]: unknown;
};
'puter.card-verification.confirm': {
user_id: number;
user_uid: string;
setup_intent_id: string;
enabled: boolean | null;
verified: boolean;
reason: string | null;
fingerprint: string | null;
funding: string | null;
country: string | null;
[key: string]: unknown;
};
'user.card-verified': {
user_id: number;
user_uid: string;
fingerprint: string | null;
funding: string | null;
country: string | null;
};
'user.username-changed': {
user_id: number;
old_username: string;
+2
View File
@@ -24,6 +24,7 @@ import { EventClient } from './event/EventClient';
import { DDBClient } from './dynamodb/DDBClient';
import { RedisClient } from './redis/RedisClient';
import { S3Client } from './s3/S3Client';
import { PreludeClient } from './prelude/PreludeClient';
import type { IPuterClientRegistry } from './types';
export const puterClients = {
@@ -34,4 +35,5 @@ export const puterClients = {
dynamo: DDBClient,
redis: RedisClient,
s3: S3Client,
prelude: PreludeClient,
} satisfies IPuterClientRegistry;
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PreludeClient } from './PreludeClient';
import type { IConfig } from '../../types';
const makeClient = (apiKey?: string) =>
new PreludeClient({
prelude: apiKey ? { apiKey } : undefined,
} as unknown as IConfig);
const okJson = (body: unknown) =>
({
ok: true,
status: 200,
json: async () => body,
}) as unknown as Response;
describe('PreludeClient', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('isConfigured reflects whether an apiKey is set', () => {
expect(makeClient('sk_test').isConfigured()).toBe(true);
expect(makeClient().isConfigured()).toBe(false);
});
describe('isCountrySupported (€0.07 cap)', () => {
const client = makeClient('sk_test');
it('allows revenue markets up to the cap (incl. the priciest)', () => {
expect(client.isCountrySupported('US')).toBe(true); // €0.0043
expect(client.isCountrySupported('DE')).toBe(true); // €0.0598
expect(client.isCountrySupported('SA')).toBe(true); // €0.0638
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
});
it('rejects countries above the cap, with no SMS, or unknown', () => {
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
expect(client.isCountrySupported('LI')).toBe(false); // null (no SMS)
expect(client.isCountrySupported('ZZ')).toBe(false); // unknown
expect(client.isCountrySupported(undefined)).toBe(false);
});
it('honors a configured maxSmsCostEur override', () => {
const strict = new PreludeClient({
prelude: { apiKey: 'sk', maxSmsCostEur: 0.01 },
} as unknown as IConfig);
expect(strict.isCountrySupported('US')).toBe(true); // €0.0043
expect(strict.isCountrySupported('DE')).toBe(false); // €0.0598 > 0.01
const loose = new PreludeClient({
prelude: { apiKey: 'sk', maxSmsCostEur: 0.5 },
} as unknown as IConfig);
expect(loose.isCountrySupported('PK')).toBe(true); // €0.3548 <= 0.5
});
});
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
fetchMock.mockResolvedValue(
okJson({ id: 'vrf_1', status: 'success' }),
);
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123', {
ip: '203.0.113.7',
});
expect(res).toEqual({ id: 'vrf_1', status: 'success' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.prelude.dev/v2/verification');
expect(init.method).toBe('POST');
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. 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');
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
device_id: 'thumb_abc123',
user_agent: 'Mozilla/5.0',
});
const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(init.body).signals).toEqual({
ip: '203.0.113.7',
device_id: 'thumb_abc123',
user_agent: 'Mozilla/5.0',
});
});
it('omits the signals object entirely when none are supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
await client.createVerification('+14155550123');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
'signals',
);
});
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({
prelude: {
apiKey: 'sk_test',
templateId: 'tmpl_puter',
senderId: 'Puter',
preferredChannel: 'sms',
},
} as unknown as IConfig);
await client.createVerification('+14155550123');
const [, init] = fetchMock.mock.calls[0];
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',
});
});
it('checkVerification POSTs target + code and returns the status', async () => {
fetchMock.mockResolvedValue(okJson({ status: 'success' }));
const client = makeClient('sk_test');
const res = await client.checkVerification('+14155550123', '123456');
expect(res).toEqual({ status: 'success' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.prelude.dev/v2/verification/check');
expect(JSON.parse(init.body)).toEqual({
target: { type: 'phone_number', value: '+14155550123' },
code: '123456',
});
});
it('throws (does not call fetch) when not configured', async () => {
const client = makeClient();
await expect(
client.createVerification('+14155550123'),
).rejects.toThrow(/not configured/i);
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws on a non-2xx Prelude response', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ message: 'bad target' }),
} as unknown as Response);
await expect(
makeClient('sk_test').checkVerification('+1', 'x'),
).rejects.toThrow(/Prelude .* failed: 400/);
});
});
@@ -0,0 +1,226 @@
/**
* 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 { IConfig } from '../../types';
import { PuterClient } from '../types';
import { COUNTRY_SMS_PRICES } from './countries.js';
const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
const REQUEST_TIMEOUT_MS = 8000;
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
const PRELUDE_CODE_SIZE = 6;
/**
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
* RCS isn't available on the destination. (Requires an RCS agent provisioned in
* the Prelude account to actually use RCS — otherwise it just falls through to
* SMS.) Override with `config.prelude.preferredChannel`.
*/
const DEFAULT_PREFERRED_CHANNEL: PreludeChannel = 'rcs';
/** Delivery channels Prelude can prioritize via `options.preferred_channel`. */
export type PreludeChannel =
| 'sms'
| 'rcs'
| 'whatsapp'
| '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
* cap covers every realistic revenue market (priciest are Germany €0.0598 and
* Saudi Arabia €0.0638) while excluding the expensive, high-fraud long tail.
* Override per-deployment with `config.prelude.maxSmsCostEur`.
*/
const DEFAULT_MAX_SMS_COST_EUR = 0.07;
/** Status returned by Prelude when creating/retrying a verification. */
export type PreludeCreateStatus =
| 'success'
| 'retry'
| 'challenged'
| 'blocked'
| 'shadow_blocked';
/** Status returned by Prelude when checking a code. */
export type PreludeCheckStatus =
| 'success'
| 'failure'
| 'expired_or_not_found'
| 'transaction_missing'
| 'transaction_mismatch';
/**
* Prelude Verify v2 client (https://docs.prelude.so/verify/v2). Sends and checks
* SMS one-time codes — Prelude generates, delivers, and validates the code, so
* we never store one ourselves. No-ops with a warning when no API key is
* configured (so dev environments don't crash); `isConfigured()` lets callers
* surface a clean "phone verification unavailable" error instead.
*/
export class PreludeClient extends PuterClient {
private apiKey: string | null = null;
constructor(config: IConfig) {
super(config);
this.apiKey = config.prelude?.apiKey ?? null;
}
override onServerStart(): void {
if (!this.apiKey) {
console.warn(
'[prelude] no apiKey configured — SMS phone verification is disabled',
);
}
}
/** True when an API key is configured and verification can be attempted. */
isConfigured(): boolean {
return !!this.apiKey;
}
/** ISO region used to parse local-format numbers (config-driven). */
get defaultCountry(): string | undefined {
return this.config.prelude?.defaultCountry;
}
/** Per-SMS cost ceiling in EUR (config override or the default cap). */
get maxSmsCostEur(): number {
return this.config.prelude?.maxSmsCostEur ?? DEFAULT_MAX_SMS_COST_EUR;
}
/**
* Whether SMS verification should be offered for a country. False when the
* country is unknown, has no SMS channel, or its rate exceeds the cost cap.
* @param iso ISO-3166 alpha-2 (e.g. 'US') — from the parsed phone number.
*/
isCountrySupported(iso: string | undefined): boolean {
if (!iso) return false; // couldn't determine country → can't price it
const price = COUNTRY_SMS_PRICES[iso.toUpperCase()];
if (!price || price.sms == null) return false;
return price.sms <= this.maxSmsCostEur;
}
async createVerification(
target: string,
signals: {
ip?: string;
device_id?: string;
user_agent?: string;
dispatch_id?: string;
} = {},
): 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.
const options: Record<string, unknown> = {
code_size: PRELUDE_CODE_SIZE,
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
// configured. See IPreludeConfig.
const { templateId, senderId } = this.config.prelude ?? {};
if (templateId) options.template_id = templateId;
if (senderId) options.sender_id = senderId;
const body: Record<string, unknown> = {
target: { type: 'phone_number', value: target },
options,
};
// Only attach signals we actually have — Prelude treats the object as
// optional and an empty one adds nothing.
const sig: Record<string, string> = {};
if (signals.ip) sig.ip = signals.ip;
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[];
}>;
}
/**
* Check a code the user entered against the active verification for `target`.
* @returns `{ status }` — `'success'` means verified.
*/
async checkVerification(
target: string,
code: string,
): Promise<{ status: PreludeCheckStatus }> {
return this.#post('/verification/check', {
target: { type: 'phone_number', value: target },
code,
}) as Promise<{ status: PreludeCheckStatus }>;
}
async #post(
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!this.apiKey) {
throw new Error('Prelude is not configured');
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(`${PRELUDE_API_BASE}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
signal: controller.signal,
});
const json = (await res.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (!res.ok) {
throw new Error(
`Prelude ${path} failed: ${res.status} ${
(json as { message?: string })?.message ?? ''
}`.trim(),
);
}
return json;
} finally {
clearTimeout(timer);
}
}
}
+952
View File
@@ -0,0 +1,952 @@
/**
* 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/>.
*/
/** Per-country Prelude SMS pricing, hardcoded from the Prelude global price
* sheet. `sms` is the average cost in EUR, or null when SMS is unavailable
* there. Used to cap phone verification to affordable countries (see
* PreludeClient.isCountrySupported). Regenerate from the price sheet when
* Prelude updates rates. */
export interface CountryPrice {
name: string;
/** Prelude SMS average in EUR, or null when SMS is not available. */
sms: number | null;
}
export const COUNTRY_SMS_PRICES: Record<string, CountryPrice> = {
AD: {
name: 'Andorra',
sms: 0.0515,
},
AE: {
name: 'United Arab Emirates',
sms: 0.0145,
},
AF: {
name: 'Afghanistan',
sms: 0.2409,
},
AG: {
name: 'Antigua and Barbuda',
sms: 0.0755,
},
AI: {
name: 'Anguilla',
sms: 0.0765,
},
AL: {
name: 'Albania',
sms: 0.0345,
},
AM: {
name: 'Armenia',
sms: 0.118,
},
AN: {
name: 'Netherlands Antilles',
sms: null,
},
AO: {
name: 'Angola',
sms: 0.0347,
},
AR: {
name: 'Argentina',
sms: 0.0614,
},
AS: {
name: 'American Samoa',
sms: 0.077,
},
AT: {
name: 'Austria',
sms: 0.014,
},
AU: {
name: 'Australia',
sms: 0.0091,
},
AW: {
name: 'Aruba',
sms: 0.0617,
},
AZ: {
name: 'Azerbaijan',
sms: 0.0969,
},
BA: {
name: 'Bosnia and Herzegovina',
sms: 0.0801,
},
BB: {
name: 'Barbados',
sms: 0.2021,
},
BD: {
name: 'Bangladesh',
sms: 0.1844,
},
BE: {
name: 'Belgium',
sms: 0.0297,
},
BF: {
name: 'Burkina Faso',
sms: 0.063,
},
BG: {
name: 'Bulgaria',
sms: 0.0799,
},
BH: {
name: 'Bahrain',
sms: 0.014,
},
BI: {
name: 'Burundi',
sms: 0.217,
},
BJ: {
name: 'Benin',
sms: 0.13,
},
BM: {
name: 'Bermuda',
sms: 0.21,
},
BN: {
name: 'Brunei',
sms: 0.035,
},
BO: {
name: 'Bolivia',
sms: 0.0708,
},
BQ: {
name: 'Caribbean Netherlands',
sms: 0.0565,
},
BR: {
name: 'Brazil',
sms: 0.0086,
},
BS: {
name: 'Bahamas',
sms: 0.0672,
},
BT: {
name: 'Bhutan',
sms: null,
},
BW: {
name: 'Botswana',
sms: 0.0235,
},
BY: {
name: 'Belarus',
sms: 0.118,
},
BZ: {
name: 'Belize',
sms: 0.1529,
},
CA: {
name: 'Canada',
sms: 0.0052,
},
CD: {
name: 'Congo RDC',
sms: 0.1112,
},
CF: {
name: 'Central African Republic',
sms: 0.2,
},
CG: {
name: 'Congo',
sms: 0.2507,
},
CH: {
name: 'Switzerland',
sms: 0.0163,
},
CI: {
name: "Cote d'Ivoire",
sms: 0.19,
},
CK: {
name: 'Cook Islands',
sms: 0.1041,
},
CL: {
name: 'Chile',
sms: 0.0027,
},
CM: {
name: 'Cameroon',
sms: 0.1504,
},
CN: {
name: 'China',
sms: 0.0053,
},
CO: {
name: 'Colombia',
sms: 0.0008,
},
CR: {
name: 'Costa Rica',
sms: 0.0045,
},
CU: {
name: 'Cuba',
sms: 0.0512,
},
CV: {
name: 'Cabo Verde',
sms: 0.2031,
},
CW: {
name: 'Curacao',
sms: 0.0138,
},
CY: {
name: 'Northern Cyprus',
sms: 0.0065,
},
CZ: {
name: 'Czech Republic',
sms: 0.0299,
},
DE: {
name: 'Germany',
sms: 0.0598,
},
DJ: {
name: 'Djibouti',
sms: 0.0897,
},
DK: {
name: 'Denmark',
sms: 0.0301,
},
DM: {
name: 'Dominica',
sms: 0.0824,
},
DO: {
name: 'Dominican Republic',
sms: 0.0351,
},
DZ: {
name: 'Algeria',
sms: 0.196,
},
EC: {
name: 'Ecuador',
sms: 0.1041,
},
EE: {
name: 'Estonia',
sms: 0.0234,
},
EG: {
name: 'Egypt',
sms: 0.1561,
},
ER: {
name: 'Eritrea',
sms: 0.0659,
},
ES: {
name: 'Spain',
sms: 0.0193,
},
ET: {
name: 'Ethiopia',
sms: 0.2741,
},
FI: {
name: 'Finland',
sms: 0.043,
},
FJ: {
name: 'Fiji',
sms: 0.069,
},
FK: {
name: 'Falkland Islands',
sms: 0.0713,
},
FM: {
name: 'Micronesia',
sms: 0.0118,
},
FO: {
name: 'Faroe Islands',
sms: 0.0319,
},
FR: {
name: 'France',
sms: 0.03,
},
GA: {
name: 'Gabon',
sms: 0.15,
},
GB: {
name: 'United Kingdom',
sms: 0.026,
},
GD: {
name: 'Grenada',
sms: null,
},
GE: {
name: 'Georgia',
sms: 0.0788,
},
GF: {
name: 'French Guiana',
sms: 0.05,
},
GG: {
name: 'Guernsey',
sms: 0.025,
},
GH: {
name: 'Ghana',
sms: 0.15,
},
GI: {
name: 'Gibraltar',
sms: 0.0134,
},
GL: {
name: 'Greenland',
sms: 0.0048,
},
GM: {
name: 'Gambia',
sms: 0.1283,
},
GN: {
name: 'Guinea',
sms: 0.2,
},
GP: {
name: 'Guadeloupe',
sms: 0.0455,
},
GQ: {
name: 'Equatorial Guinea',
sms: 0.099,
},
GR: {
name: 'Greece',
sms: 0.0296,
},
GT: {
name: 'Guatemala',
sms: 0.1202,
},
GU: {
name: 'Guam',
sms: 0.02,
},
GW: {
name: 'Guinea-Bissau',
sms: 0.1566,
},
GY: {
name: 'Guyana',
sms: 0.2178,
},
HK: {
name: 'Hong Kong',
sms: 0.038,
},
HN: {
name: 'Honduras',
sms: 0.162,
},
HR: {
name: 'Croatia',
sms: 0.03,
},
HT: {
name: 'Haiti',
sms: 0.09,
},
HU: {
name: 'Hungary',
sms: 0.029,
},
ID: {
name: 'Indonesia',
sms: 0.243,
},
IE: {
name: 'Ireland',
sms: 0.0305,
},
IL: {
name: 'Israel',
sms: 0.01,
},
IM: {
name: 'Isle of Man',
sms: 0.0385,
},
IN: {
name: 'India',
sms: 0.0375,
},
IQ: {
name: 'Iraq',
sms: 0.151,
},
IR: {
name: 'Iran',
sms: 0.14,
},
IS: {
name: 'Iceland',
sms: 0.0493,
},
IT: {
name: 'Italy',
sms: 0.0245,
},
JE: {
name: 'Jersey',
sms: 0.025,
},
JM: {
name: 'Jamaica',
sms: 0.1525,
},
JO: {
name: 'Jordan',
sms: 0.2094,
},
JP: {
name: 'Japan',
sms: 0.017,
},
KE: {
name: 'Kenya',
sms: 0.125,
},
KG: {
name: 'Kyrgyzstan',
sms: 0.15,
},
KH: {
name: 'Cambodia',
sms: 0.1529,
},
KI: {
name: 'Kiribati',
sms: 0.025,
},
KM: {
name: 'Comoros',
sms: 0.15,
},
KN: {
name: 'Saint Kitts and Nevis',
sms: 0.1295,
},
KR: {
name: 'South Korea',
sms: 0.006,
},
KW: {
name: 'Kuwait',
sms: 0.145,
},
KY: {
name: 'Cayman Islands',
sms: 0.2172,
},
KZ: {
name: 'Kazakhstan',
sms: 0.202,
},
LA: {
name: 'Laos',
sms: 0.15,
},
LB: {
name: 'Lebanon',
sms: 0.153,
},
LC: {
name: 'Saint Lucia',
sms: 0.0892,
},
LI: {
name: 'Liechtenstein',
sms: null,
},
LK: {
name: 'Sri Lanka',
sms: 0.3593,
},
LR: {
name: 'Liberia',
sms: 0.1448,
},
LS: {
name: 'Lesotho',
sms: 0.0301,
},
LT: {
name: 'Lithuania',
sms: 0.0261,
},
LU: {
name: 'Luxembourg',
sms: 0.034,
},
LV: {
name: 'Latvia',
sms: 0.028,
},
LY: {
name: 'Libya',
sms: 0.1898,
},
MA: {
name: 'Morocco',
sms: 0.105,
},
MC: {
name: 'Monaco',
sms: 0.1219,
},
MD: {
name: 'Moldova',
sms: 0.065,
},
ME: {
name: 'Montenegro',
sms: 0.07,
},
MG: {
name: 'Madagascar',
sms: 0.24,
},
MH: {
name: '',
sms: 0.032,
},
MK: {
name: 'Macedonia',
sms: 0.0096,
},
ML: {
name: 'Mali',
sms: 0.136,
},
MM: {
name: 'Myanmar',
sms: 0.3037,
},
MN: {
name: 'Mongolia',
sms: 0.183,
},
MO: {
name: 'Macao',
sms: 0.005,
},
MP: {
name: 'Northern Mariana Islands',
sms: 0.0669,
},
MQ: {
name: 'Martinique',
sms: 0.0455,
},
MR: {
name: 'Mauritania',
sms: 0.161,
},
MS: {
name: 'Montserrat',
sms: 0.0725,
},
MT: {
name: 'Malta',
sms: 0.0343,
},
MU: {
name: 'Mauritius',
sms: 0.1431,
},
MV: {
name: 'Maldives',
sms: 0.145,
},
MW: {
name: 'Malawi',
sms: 0.19,
},
MX: {
name: 'Mexico',
sms: 0.0021,
},
MY: {
name: 'Malaysia',
sms: 0.08,
},
MZ: {
name: 'Mozambique',
sms: 0.2266,
},
NA: {
name: 'Namibia',
sms: 0.0173,
},
NC: {
name: 'New Caledonia',
sms: 0.052,
},
NE: {
name: 'Niger',
sms: 0.143,
},
NG: {
name: 'Nigeria',
sms: 0.198,
},
NI: {
name: 'Nicaragua',
sms: 0.1033,
},
NL: {
name: 'Netherlands',
sms: 0.046,
},
NO: {
name: 'Norway',
sms: 0.03,
},
NP: {
name: 'Nepal',
sms: 0.1645,
},
NR: {
name: 'Nauru',
sms: null,
},
NU: {
name: 'Niue',
sms: null,
},
NZ: {
name: 'New Zealand',
sms: 0.0372,
},
OM: {
name: 'Oman',
sms: 0.0713,
},
PA: {
name: 'Panama',
sms: 0.07,
},
PE: {
name: 'Peru',
sms: 0.13,
},
PF: {
name: 'French Polynesia',
sms: 0.0518,
},
PG: {
name: 'Papua New Guinea',
sms: 0.15,
},
PH: {
name: 'Philippines',
sms: 0.1237,
},
PK: {
name: 'Pakistan',
sms: 0.3548,
},
PL: {
name: 'Poland',
sms: 0.011,
},
PM: {
name: 'Saint Pierre and Miquelon',
sms: 0.0905,
},
PR: {
name: 'Puerto Rico',
sms: 0.02,
},
PS: {
name: 'Palestine',
sms: 0.2541,
},
PT: {
name: 'Portugal',
sms: 0.009,
},
PW: {
name: 'Palau',
sms: null,
},
PY: {
name: 'Paraguay',
sms: 0.0236,
},
QA: {
name: 'Qatar',
sms: 0.1393,
},
RE: {
name: 'Reunion',
sms: 0.034,
},
RO: {
name: 'Romania',
sms: 0.0255,
},
RS: {
name: 'Serbia',
sms: 0.1935,
},
RU: {
name: 'Russia',
sms: 0.2028,
},
RW: {
name: 'Rwanda',
sms: 0.1177,
},
SA: {
name: 'Saudi Arabia',
sms: 0.0638,
},
SB: {
name: 'Solomon Islands',
sms: 0.0357,
},
SC: {
name: 'Seychelles',
sms: 0.0404,
},
SD: {
name: 'Sudan',
sms: 0.224,
},
SE: {
name: 'Sweden',
sms: 0.026,
},
SG: {
name: 'Singapore',
sms: 0.0256,
},
SI: {
name: 'Slovenia',
sms: 0.1,
},
SK: {
name: 'Slovakia',
sms: 0.0245,
},
SL: {
name: 'Sierra Leone',
sms: 0.2322,
},
SM: {
name: 'San Marino',
sms: null,
},
SN: {
name: 'Senegal',
sms: 0.1151,
},
SO: {
name: 'Somalia',
sms: 0.06,
},
SR: {
name: 'Suriname',
sms: 0.119,
},
SS: {
name: 'South Sudan',
sms: 0.15,
},
ST: {
name: 'Sao Tome and Principe',
sms: 0.0133,
},
SV: {
name: 'El Salvador',
sms: 0.06,
},
SX: {
name: 'Sint Maarten',
sms: 0.0623,
},
SY: {
name: 'Syria',
sms: 0.221,
},
SZ: {
name: 'Eswatini',
sms: 0.12,
},
TC: {
name: 'Turks and Caicos Islands',
sms: null,
},
TD: {
name: 'Chad',
sms: 0.1711,
},
TG: {
name: 'Togo',
sms: 0.1806,
},
TH: {
name: 'Thailand',
sms: 0.003,
},
TJ: {
name: 'Tajikistan',
sms: 0.2626,
},
TL: {
name: 'Timor-Leste',
sms: 0.0675,
},
TM: {
name: 'Turkmenistan',
sms: 0.1677,
},
TN: {
name: 'Tunisia',
sms: 0.225,
},
TO: {
name: 'Tonga',
sms: 0.0902,
},
TR: {
name: 'Turkey',
sms: 0.0008,
},
TT: {
name: 'Trinidad and Tobago',
sms: 0.1556,
},
TW: {
name: 'Taiwan',
sms: 0.0165,
},
TZ: {
name: 'Tanzania',
sms: 0.2753,
},
UA: {
name: 'Ukraine',
sms: 0.094,
},
UG: {
name: 'Uganda',
sms: 0.1707,
},
US: {
name: 'United States',
sms: 0.0043,
},
UY: {
name: 'Uruguay',
sms: 0.0174,
},
UZ: {
name: 'Uzbekistan',
sms: 0.292,
},
VC: {
name: 'Saint Vincent and The Grenadines',
sms: 0.1216,
},
VE: {
name: 'Venezuela',
sms: 0.0427,
},
VG: {
name: 'British Virgin Islands',
sms: 0.083,
},
VI: {
name: 'Virgin Island',
sms: 0.0017,
},
VN: {
name: 'Vietnam',
sms: 0.0885,
},
VU: {
name: 'Vanuatu',
sms: 0.1347,
},
WF: {
name: 'Wallis and Futuna',
sms: 0.09,
},
WS: {
name: 'Samoa',
sms: 0.2,
},
XK: {
name: 'Kosovo',
sms: 0.1394,
},
YE: {
name: 'Yemen',
sms: 0.1486,
},
YT: {
name: 'Mayotte',
sms: 0.06,
},
ZA: {
name: 'South Africa',
sms: 0.039,
},
ZM: {
name: 'Zambia',
sms: 0.21,
},
ZW: {
name: 'Zimbabwe',
sms: 0.13,
},
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+282 -15
View File
@@ -18,6 +18,7 @@
*/
import type { Request, Response } from 'express';
import type { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
@@ -124,6 +125,14 @@ const makeRes = () => {
const withActor = async <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
runWithContext({ actor }, fn);
const streamToString = async (stream: Readable): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Buffer));
}
return Buffer.concat(chunks).toString('utf8');
};
// ── /startBatchWrite ────────────────────────────────────────────────
describe('FSController.startBatchWrites', () => {
@@ -549,9 +558,7 @@ describe('FSController.completeBatchWrites', () => {
.body as SignedWriteResponse[];
const emitSpy = vi.spyOn(server.clients.event, 'emit');
let updatedCall:
| (typeof emitSpy.mock.calls)[number]
| undefined;
let updatedCall: (typeof emitSpy.mock.calls)[number] | undefined;
try {
await withActor(actor, () =>
controller.completeBatchWrites(
@@ -726,9 +733,9 @@ describe('FSController.statEntry', () => {
...makeReq({ body: { path: '/x' }, actor }),
actor: undefined,
} as unknown as Request;
await expect(
controller.statEntry(req, res),
).rejects.toMatchObject({ statusCode: 401 });
await expect(controller.statEntry(req, res)).rejects.toMatchObject({
statusCode: 401,
});
});
});
@@ -898,9 +905,7 @@ describe('FSController.searchEntries', () => {
).toBe(true);
}
expect(
results.some(
(r) => r.path === `/${username}/Documents/${needle}`,
),
results.some((r) => r.path === `/${username}/Documents/${needle}`),
).toBe(false);
});
@@ -1318,10 +1323,6 @@ describe('FSController.copyEntry', () => {
// ── /read (readEntry, full read) ────────────────────────────────────
describe('FSController.readEntry (file streaming)', () => {
// makeRes here adds a real Writable surface so that
// `pipeline(download.body, res)` inside the controller can pipe
// the in-memory S3 stream into the test's response and capture
// bytes for assertions.
const makeStreamingRes = () => {
const captured = {
statusCode: 200,
@@ -1329,7 +1330,8 @@ describe('FSController.readEntry (file streaming)', () => {
bodyChunks: [] as Buffer[],
};
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Writable } = require('node:stream') as typeof import('node:stream');
const { Writable } =
require('node:stream') as typeof import('node:stream');
const writable = new Writable({
write(chunk: Buffer, _enc, cb) {
captured.bodyChunks.push(chunk);
@@ -1389,7 +1391,9 @@ describe('FSController.readEntry (file streaming)', () => {
// Pipeline awaits the stream-end on success.
expect(captured.statusCode).toBe(200);
expect(captured.headers['Content-Type']).toMatch(/text\/plain/);
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
expect(captured.headers['Content-Length']).toBe(
String(body.byteLength),
);
expect(captured.headers['Content-Disposition']).toMatch(
/inline; filename=/,
);
@@ -1833,3 +1837,266 @@ describe('FSController.mkshortcutEntry', () => {
expect(body.isShortcut).toBe(true);
});
});
describe('FSController metadata.objectKey injection', () => {
const writeFile = async (
actor: Actor,
path: string,
content: string,
metadata?: Record<string, unknown>,
) => {
await withActor(actor, () =>
controller.write(
makeReq({
body: {
fileMetadata: {
path,
size: Buffer.byteLength(content),
contentType: 'text/plain',
overwrite: true,
...(metadata ? { metadata } : {}),
},
fileContent: content,
encoding: 'utf8',
},
actor,
}) as unknown as Request<
Record<string, never>,
null,
import('./requestTypes.js').WriteRequest
>,
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(path, {
skipCache: true,
});
if (!entry) throw new Error(`entry not found after write: ${path}`);
return entry;
};
it("does not stream another user's file when a client injects metadata.objectKey on write", async () => {
const victim = await makeUser();
const attacker = await makeUser();
const victimSecret = 'VICTIM-TOP-SECRET-PAYLOAD';
const attackerDecoy = 'attacker-own-decoy-bytes';
const victimEntry = await writeFile(
victim.actor,
`/${victim.actor.user!.username}/Documents/secret.txt`,
victimSecret,
);
const victimRead = await server.services.fs.readContent(victimEntry);
expect(await streamToString(victimRead.body)).toBe(victimSecret);
const attackerEntry = await writeFile(
attacker.actor,
`/${attacker.actor.user!.username}/Documents/loot.txt`,
attackerDecoy,
{ objectKey: victimEntry.uuid },
);
const persisted = attackerEntry.metadata
? (JSON.parse(attackerEntry.metadata) as Record<string, unknown>)
: {};
expect(persisted.objectKey).toBeUndefined();
const attackerRead =
await server.services.fs.readContent(attackerEntry);
const got = await streamToString(attackerRead.body);
expect(got).toBe(attackerDecoy);
expect(got).not.toBe(victimSecret);
});
it('read path ignores a divergent metadata.objectKey on an already-poisoned row', async () => {
const victim = await makeUser();
const attacker = await makeUser();
const victimSecret = 'VICTIM-SECRET-FOR-POISON-TEST';
const attackerDecoy = 'attacker-decoy-for-poison-test';
const victimEntry = await writeFile(
victim.actor,
`/${victim.actor.user!.username}/Documents/secret2.txt`,
victimSecret,
);
const attackerEntry = await writeFile(
attacker.actor,
`/${attacker.actor.user!.username}/Documents/loot2.txt`,
attackerDecoy,
);
await server.stores.fsEntry.updateEntry(attackerEntry.uuid, {
metadata: JSON.stringify({ objectKey: victimEntry.uuid }),
});
const poisoned = await server.stores.fsEntry.getEntryByPath(
attackerEntry.path,
{ skipCache: true },
);
if (!poisoned) throw new Error('poisoned entry not found');
expect(
(JSON.parse(poisoned.metadata!) as { objectKey: string }).objectKey,
).toBe(victimEntry.uuid);
const read = await server.services.fs.readContent(poisoned);
expect(await streamToString(read.body)).toBe(attackerDecoy);
});
it('scrubs objectKey from move newMetadata while preserving legit trash metadata', async () => {
const victim = await makeUser();
const attacker = await makeUser();
const victimSecret = 'VICTIM-SECRET-FOR-MOVE-TEST';
const attackerDecoy = 'attacker-decoy-for-move-test';
const username = attacker.actor.user!.username!;
const victimEntry = await writeFile(
victim.actor,
`/${victim.actor.user!.username}/Documents/secret3.txt`,
victimSecret,
);
const attackerEntry = await writeFile(
attacker.actor,
`/${username}/Documents/loot3.txt`,
attackerDecoy,
);
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
{ skipCache: true },
);
if (!documents) throw new Error('Documents dir not found');
const moved = await withActor(attacker.actor, () =>
server.services.fs.move(attacker.userId, {
source: attackerEntry,
destinationParent: documents,
newName: 'loot3-moved.txt',
newMetadata: {
original_path: `/${username}/Documents/loot3.txt`,
trashed_ts: 1700000000,
objectKey: victimEntry.uuid,
},
}),
);
const persisted = JSON.parse(moved.metadata!) as Record<
string,
unknown
>;
expect(persisted.objectKey).toBeUndefined();
expect(persisted.original_path).toBe(
`/${username}/Documents/loot3.txt`,
);
expect(persisted.trashed_ts).toBe(1700000000);
const read = await server.services.fs.readContent(moved);
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 ────────────────────────────────────────────────────────────
@@ -46,14 +46,6 @@ import {
} from './legacyFsHelpers.js';
import { RouteOptions } from '../../core/http/index.js';
/**
* Legacy FS routes, implemented as thin shims over `FSService`.
*
* Each shim parses the request shape (FSNodeParam-style `{ path, uid, id }`
* or `{ parent, name }`), invokes the service method, and returns the
* snake_case response clients expect.
*/
type RouterCache = Map<string, RequestHandler | null>;
const additionalRoutePaths: Record<string, string> = {};
@@ -166,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()
: [];
@@ -202,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 === '',
});
}
}
@@ -563,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, {
@@ -981,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: [],
};
@@ -1523,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',
@@ -1631,17 +1621,6 @@ export class LegacyFSController extends PuterController {
download.body.pipe(res);
};
// Helpers for writeFile
// -- GUI event emission -------------------------------------------
//
// Fire-and-forget `outer.gui.item.*` events so SocketService,
// BroadcastService, WorkerDriver (hot-reload), and cache-invalidation
// listeners pick up mutations made through the legacy (bare-path) routes.
// FSController (v2-native /fs/* routes) emits these from its own handlers;
// LegacyFSController delegates to the same FSService but needs its
// own emissions because the service layer deliberately doesn't emit GUI
// events (that's a controller concern).
async #emitGuiEvent(
eventName:
| 'outer.gui.item.added'
@@ -2215,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));
@@ -139,6 +139,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]
@@ -1506,6 +1506,212 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
});
});
// ── Inline compaction ───────────────────────────────────────────────
describe('PuterAIController inline compaction', () => {
// Extract the single `event: compaction\ndata: {...}` SSE frame.
const compactionFrame = (out: string): string | null => {
const m = out.match(/event: compaction\ndata: [^\n]*\n\n/);
return m ? m[0] : null;
};
it('forwards the compaction opt-in to the driver (/responses)', async () => {
const spy = stubChatComplete({
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
});
await controller.openaiResponses(
makeReq({
body: { model: 'gpt-test', input: 'hi', compaction: true },
actor: makeUserActor(),
}),
makeRes().res,
);
expect(spy.mock.calls[0]![0].compaction).toBe(true);
});
it('round-trips a compaction `input` item into a messages compaction item', async () => {
const spy = stubChatComplete({
message: { role: 'assistant', content: 'ack' },
finish_reason: 'stop',
});
await controller.openaiResponses(
makeReq({
body: {
model: 'gpt-test',
input: [
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
],
},
actor: makeUserActor(),
}),
makeRes().res,
);
expect(spy.mock.calls[0]![0].messages).toContainEqual({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
});
it('emits a canonical compaction SSE event and output item (/responses stream)', async () => {
stubChatComplete({
dataType: 'stream',
content_type: 'application/x-ndjson',
stream: ndjsonStreamFrom([
{ type: 'text', text: 'hi' },
{ type: 'compaction', id: 'cmpct_9', encrypted_content: 'ENC9' },
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
]),
});
const { res, captured } = makeRes();
await controller.openaiResponses(
makeReq({
body: {
model: 'gpt-test',
input: 'hi',
stream: true,
compaction: true,
},
actor: makeUserActor(),
}),
res,
);
await new Promise<void>((resolve) => setImmediate(resolve));
const out = captured.written.join('');
expect(compactionFrame(out)).toBe(
'event: compaction\ndata: {"type":"compaction","id":"cmpct_9","encrypted_content":"ENC9"}\n\n',
);
// Native shape also lands in the final response.completed output[].
const completed = out
.split('event: response.completed\n')[1]
?.split('\n\n')[0];
expect(completed).toContain('"type":"compaction"');
expect(completed).toContain('"encrypted_content":"ENC9"');
});
it('emits the compaction item in non-streaming /responses output', async () => {
stubChatComplete({
message: { role: 'assistant', content: 'done' },
finish_reason: 'stop',
compaction: { id: 'cmpct_n', encrypted_content: 'ENCN' },
});
const { res, captured } = makeRes();
await controller.openaiResponses(
makeReq({
body: { model: 'gpt-test', input: 'hi', compaction: true },
actor: makeUserActor(),
}),
res,
);
const body = captured.body as { output: Array<Record<string, unknown>> };
expect(body.output).toContainEqual(
expect.objectContaining({
type: 'compaction',
encrypted_content: 'ENCN',
}),
);
});
it('emits an identical canonical compaction SSE event on the Anthropic surface', async () => {
// /responses frame
stubChatComplete({
dataType: 'stream',
content_type: 'application/x-ndjson',
stream: ndjsonStreamFrom([
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
]),
});
const r1 = makeRes();
await controller.openaiResponses(
makeReq({
body: { model: 'gpt-test', input: 'hi', stream: true },
actor: makeUserActor(),
}),
r1.res,
);
await new Promise<void>((resolve) => setImmediate(resolve));
// /anthropic/v1/messages frame
stubChatComplete({
dataType: 'stream',
content_type: 'application/x-ndjson',
stream: ndjsonStreamFrom([
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
]),
});
const r2 = makeRes();
await controller.anthropicMessages(
makeReq({
body: {
model: 'claude-test',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
},
actor: makeUserActor(),
}),
r2.res,
);
await new Promise<void>((resolve) => setImmediate(resolve));
const a = compactionFrame(r1.captured.written.join(''));
const b = compactionFrame(r2.captured.written.join(''));
expect(a).not.toBeNull();
expect(a).toBe(b); // byte-identical streaming shape across providers
});
it('renders a native compaction content block in non-streaming /messages', async () => {
stubChatComplete({
message: { role: 'assistant', content: 'done' },
finish_reason: 'stop',
compaction: { id: 'cmpct_m', encrypted_content: 'ENCM' },
});
const { res, captured } = makeRes();
await controller.anthropicMessages(
makeReq({
body: {
model: 'claude-test',
messages: [{ role: 'user', content: 'hi' }],
},
actor: makeUserActor(),
}),
res,
);
const body = captured.body as { content: Array<Record<string, unknown>> };
expect(body.content).toContainEqual({
type: 'compaction',
id: 'cmpct_m',
encrypted_content: 'ENCM',
});
});
it('does not emit compaction frames for a normal stream (regression)', async () => {
stubChatComplete({
dataType: 'stream',
content_type: 'application/x-ndjson',
stream: ndjsonStreamFrom([
{ type: 'text', text: 'hello' },
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
]),
});
const { res, captured } = makeRes();
await controller.openaiResponses(
makeReq({
body: { model: 'gpt-test', input: 'hi', stream: true },
actor: makeUserActor(),
}),
res,
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(captured.written.join('')).not.toContain('event: compaction');
});
});
// ── Model details listing ───────────────────────────────────────────
describe('PuterAIController model details', () => {
@@ -587,6 +587,15 @@ export class PuterAIController extends PuterController {
? { metadata: body.metadata as Record<string, string> }
: {}),
...(body.conversation ? { conversation: body.conversation } : {}),
...(body.context_management !== undefined
? { context_management: body.context_management }
: {}),
...(body.compaction !== undefined
? {
compaction:
body.compaction as ICompleteArguments['compaction'],
}
: {}),
...(body.previous_response_id
? { previous_response_id: String(body.previous_response_id) }
: {}),
@@ -750,6 +759,30 @@ export class PuterAIController extends PuterController {
output_index: outputIndex,
item,
});
} else if (ev.type === 'compaction') {
// Native shape in the final `output[]`, plus the
// canonical SSE event shared with the Anthropic surface.
const item = {
type: 'compaction',
...(ev.id !== undefined ? { id: ev.id } : {}),
encrypted_content: ev.encrypted_content,
};
const outputIndex = output.length;
output.push(item);
sendEvent({
type: 'response.output_item.added',
output_index: outputIndex,
item,
});
sendEvent({
type: 'response.output_item.done',
output_index: outputIndex,
item,
});
writeCompactionEvent(res, {
id: ev.id,
encrypted_content: ev.encrypted_content,
});
} else if (ev.type === 'usage') {
usage = buildResponsesUsage(
ev.usage as Record<string, unknown>,
@@ -869,6 +902,15 @@ export class PuterAIController extends PuterController {
...(body.max_tokens !== undefined
? { max_tokens: Number(body.max_tokens) }
: {}),
...(body.context_management !== undefined
? { context_management: body.context_management }
: {}),
...(body.compaction !== undefined
? {
compaction:
body.compaction as ICompleteArguments['compaction'],
}
: {}),
...(body.provider
? { provider: toStringOrEmpty(body.provider) }
: { provider: DEFAULTS.anthropic }),
@@ -968,6 +1010,14 @@ export class PuterAIController extends PuterController {
},
});
closeBlock();
} else if (ev.type === 'compaction') {
// Close any open content block, then emit the canonical
// compaction SSE event — byte-identical to /responses.
closeBlock();
writeCompactionEvent(res, {
id: ev.id,
encrypted_content: ev.encrypted_content,
});
} else if (ev.type === 'usage') {
usage = ev.usage as Record<string, unknown>;
}
@@ -1026,6 +1076,18 @@ export class PuterAIController extends PuterController {
if (textContent)
contentBlocks.push({ type: 'text', text: textContent });
contentBlocks.push(...toolUseBlocks);
// Native Anthropic-shaped compaction block (non-streaming bodies stay
// provider-native, unlike the unified streaming event).
const compaction = (
messageResult as { compaction?: Record<string, unknown> }
).compaction;
if (compaction) {
contentBlocks.push({
type: 'compaction',
...(compaction.id !== undefined ? { id: compaction.id } : {}),
encrypted_content: compaction.encrypted_content,
});
}
if (contentBlocks.length === 0)
contentBlocks.push({ type: 'text', text: '' });
@@ -1082,6 +1144,25 @@ const setSseHeaders = (res: Response): void => {
res.setHeader('Connection', 'keep-alive');
};
/**
* Inline-compaction is emitted in a single canonical SSE shape that is
* byte-identical across the `/responses` and `/anthropic/v1/messages` streaming
* surfaces, so a streaming client parses compaction the same way regardless of
* which upstream served the request. (Non-streaming bodies stay provider-native.)
*/
const writeCompactionEvent = (
res: Response,
compaction: { id?: unknown; encrypted_content?: unknown },
): void => {
const payload = {
type: 'compaction',
...(compaction.id !== undefined ? { id: compaction.id } : {}),
encrypted_content: compaction.encrypted_content,
};
res.write(`event: compaction\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
/**
* The chat driver returns either a stream-result envelope or a plain
* message result. Proxy routes invoked with `stream: true` expect the
@@ -1325,6 +1406,17 @@ const responseInputToMessages = (input: unknown): unknown[] => {
if (!item || typeof item !== 'object') continue;
const it = item as Record<string, unknown>;
if (it.type === 'compaction') {
// Round-tripped compaction artifact: preserve it as a bare item so
// `normalize_single_message` wraps it into an internal compaction
// content block (providers map it back to their native input shape).
messages.push({
type: 'compaction',
...(it.id !== undefined ? { id: it.id } : {}),
encrypted_content: it.encrypted_content,
});
continue;
}
if (it.type === 'function_call_output') {
messages.push({
role: 'tool',
@@ -1416,6 +1508,16 @@ const responseOutputFromResult = (
});
}
const compaction = (result as { compaction?: Record<string, unknown> })
.compaction;
if (compaction) {
output.push({
id: (compaction.id as string | undefined) || generateId('cmpct'),
type: 'compaction',
encrypted_content: compaction.encrypted_content,
});
}
return output;
};
@@ -137,6 +137,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),
});
});
@@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => {
expect(captured.body).toMatchObject({
name: 'Puter',
environment: 'dev',
disable_user_signup: false,
});
});
});
@@ -4,6 +4,7 @@ import type { Request, Response } from 'express';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { hash as bcryptHash } from 'bcrypt';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -228,6 +229,112 @@ describe('WebDAVController', () => {
});
});
describe('pending-verification gate', () => {
// WebDAV must enforce the same gate every other authenticated route
// gets from requireVerifiedAccount — it dispatches off a single use()
// with no route options, so the middleware is never wired in and it
// has to call assertVerifiedAccount itself. Without it, an account
// still pending email/phone/card verification could read/write its
// whole filesystem over the `dav` subdomain.
const gatedActor = (flags: Record<string, unknown>) => ({
user: {
id: 1,
uuid: 'gated-uuid',
username: 'gated',
...flags,
},
});
it('rejects a session actor pending phone verification with 403', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
actor: gatedActor({ requires_phone_verification: true }),
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('rejects a session actor pending card verification with 403', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'GET',
actor: gatedActor({ requires_card_verification: true }),
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('rejects a session actor with an unconfirmed email with 403', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
actor: gatedActor({
requires_email_confirmation: true,
email_confirmed: false,
}),
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('enforces the gate on the Basic-auth path (flags carried onto the built actor)', async () => {
const username = `webdav-gated-${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, {
requires_phone_verification: 1,
});
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'PROPFIND',
headers: {
authorization: basicAuth(username, 'correct-horse'),
},
}),
res,
noop,
);
expect(captured.statusCode).toBe(403);
});
it('lets a fully-verified session actor through the gate', async () => {
const { res, captured } = makeRes();
await dispatchMiddleware(
makeReq({
method: 'OPTIONS',
actor: gatedActor({
requires_phone_verification: false,
requires_card_verification: false,
requires_email_confirmation: false,
}),
}),
res,
noop,
);
expect(captured.statusCode).toBe(200);
});
});
describe('unsupported methods', () => {
it('returns 405 for unknown HTTP methods', async () => {
const { res, captured } = makeRes();
@@ -23,6 +23,7 @@ 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 type { PuterRouter } from '../../core/http/PuterRouter.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
@@ -89,6 +90,16 @@ 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.
assertVerifiedAccount(actor.user);
// Expand `~`/`~/...` against the authenticated actor's username.
// WebDAV doesn't standardize `~`, but some clients do — and the
// pre-existing behaviour silently expanded it via the FS store.
@@ -233,6 +244,15 @@ export class WebDAVController extends PuterController {
email_confirmed: user.email_confirmed ?? false,
requires_email_confirmation:
user.requires_email_confirmation ?? false,
// Carry the signup-time verification flags so the
// assertVerifiedAccount() gate in #dispatch can see them on
// the Basic-auth path too (the cookie / `-token` paths get
// them from AuthService#actorUserFromRow). Omitting these is
// what let phone/card-gated accounts through WebDAV.
requires_phone_verification:
user.requires_phone_verification ?? false,
requires_card_verification:
user.requires_card_verification ?? false,
},
};
}
@@ -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;
@@ -66,6 +73,21 @@ declare global {
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
is_custom_domain?: boolean;
/**
* Coarse server-derived request fingerprint (IP + UA + accept
* headers), populated by the global fingerprint middleware. Always
* present; the same value the rate limiter keys on.
*/
networkFingerprint?: string;
/**
* Client-supplied device fingerprint (ThumbmarkJS hash) from the
* body or `x-puter-device-fingerprint` header, populated by the
* global fingerprint middleware. Present only when the client sent a
* well-shaped value; spoofable but stable per device across IPs.
*/
deviceFingerprint?: string;
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
cookies?: Record<string, 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) {
@@ -0,0 +1,93 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import { createFingerprintMiddleware } from './fingerprint';
const VALID_FP = 'dev-fingerprint-abc123';
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
const middleware = createFingerprintMiddleware();
const req = {
headers: {},
...reqPartial,
} as unknown as Request;
const next = vi.fn();
middleware(req, {} as Response, next);
return { req, next };
};
describe('createFingerprintMiddleware', () => {
it('always sets a well-formed networkFingerprint and calls next', () => {
const { req, next } = run({ ip: '203.0.113.1' });
expect(req.networkFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/);
expect(next).toHaveBeenCalledOnce();
});
it('networkFingerprint is stable for identical inputs and varies by UA', () => {
const headers = { 'user-agent': 'UA/1.0' };
const a = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
const b = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
const c = run({
ip: '203.0.113.1',
headers: { 'user-agent': 'UA/2.0' },
}).req.networkFingerprint;
expect(a).toBe(b);
expect(a).not.toBe(c);
});
it('reads a well-shaped device fingerprint from the body', () => {
const { req } = run({ body: { fingerprint: VALID_FP } });
expect(req.deviceFingerprint).toBe(VALID_FP);
});
it('falls back to the x-puter-device-fingerprint header', () => {
const { req } = run({
headers: { 'x-puter-device-fingerprint': VALID_FP },
});
expect(req.deviceFingerprint).toBe(VALID_FP);
});
it('prefers the body fingerprint over the header', () => {
const { req } = run({
body: { fingerprint: VALID_FP },
headers: { 'x-puter-device-fingerprint': 'other-fingerprint-xyz' },
});
expect(req.deviceFingerprint).toBe(VALID_FP);
});
it('drops a malformed fingerprint to undefined', () => {
expect(run({ body: { fingerprint: 'bad fp!' } }).req.deviceFingerprint)
.toBeUndefined();
expect(run({ body: { fingerprint: 'short' } }).req.deviceFingerprint)
.toBeUndefined();
expect(
run({ body: { fingerprint: 123 as unknown as string } }).req
.deviceFingerprint,
).toBeUndefined();
});
it('leaves deviceFingerprint undefined when nothing was supplied', () => {
const { req } = run({ ip: '203.0.113.1' });
expect(req.deviceFingerprint).toBeUndefined();
});
});
@@ -0,0 +1,76 @@
/**
* 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 { computeNetworkFingerprint } from './rateLimit.js';
import '../expressAugmentation';
/**
* Conservative charset/length for the client-supplied device fingerprint. It
* becomes part of KV keys downstream (abuse extension), so anything outside
* this shape is dropped to `undefined` rather than trusted. Kept in sync with
* the abuse extension's `DEVICE_FINGERPRINT_SHAPE`; the core only enforces the
* shape, never any abuse policy built on the value.
*/
const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
/** Header the GUI may send the device fingerprint on for non-signup requests. */
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
function readDeviceFingerprint(req: {
body?: unknown;
headers?: Record<string, unknown>;
}): string | undefined {
// Body first (what /signup already sends), then a header fallback so
// authenticated, bodyless-or-different-shape requests can still carry it.
const body = req.body as { fingerprint?: unknown } | undefined;
const candidate =
(typeof body?.fingerprint === 'string'
? body.fingerprint
: undefined) ??
(typeof req.headers?.[DEVICE_FINGERPRINT_HEADER] === 'string'
? (req.headers[DEVICE_FINGERPRINT_HEADER] as string)
: undefined);
if (candidate && DEVICE_FINGERPRINT_SHAPE.test(candidate)) return candidate;
return undefined;
}
/**
* 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.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.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
* Never rejects — a missing/invalid device fingerprint is simply absent.
*/
export const createFingerprintMiddleware = (): RequestHandler => {
return (req, _res, next) => {
req.networkFingerprint = computeNetworkFingerprint(req);
req.deviceFingerprint = readDeviceFingerprint(req);
next();
};
};
+50 -7
View File
@@ -25,7 +25,7 @@ import {
adminOnlyGate,
allowedAppIdsGate,
requireAuthGate,
requireEmailConfirmedGate,
requireVerifiedAccount,
requireNonAccessTokenGate,
requireUserActorGate,
requireVerifiedGate,
@@ -440,11 +440,11 @@ describe('requireVerifiedGate', () => {
});
});
// ── requireEmailConfirmedGate ───────────────────────────────────────
// ── requireVerifiedAccount ──────────────────────────────────────────
describe('requireEmailConfirmedGate', () => {
describe('requireVerifiedAccount', () => {
it('passes through users that do not require confirmation (e.g. legacy/temp)', () => {
const got = runGate(requireEmailConfirmedGate(), {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
@@ -457,7 +457,7 @@ describe('requireEmailConfirmedGate', () => {
});
it('passes through confirmed users even when confirmation is required', () => {
const got = runGate(requireEmailConfirmedGate(), {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
@@ -470,7 +470,7 @@ describe('requireEmailConfirmedGate', () => {
});
it('returns 403 email_confirmation_required for pending-confirmation users', () => {
const got = runGate(requireEmailConfirmedGate(), {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
@@ -482,8 +482,51 @@ describe('requireEmailConfirmedGate', () => {
expectHttpError(got, 403, 'email_confirmation_required');
});
it('returns 403 phone_verification_required while the phone gate is set', () => {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
requires_email_confirmation: false,
email_confirmed: true,
requires_phone_verification: true,
},
},
});
expectHttpError(got, 403, 'phone_verification_required');
});
it('returns 403 card_verification_required while the card gate is set', () => {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
requires_email_confirmation: false,
email_confirmed: true,
requires_card_verification: true,
},
},
});
expectHttpError(got, 403, 'card_verification_required');
});
it('passes through once every gate is cleared', () => {
const got = runGate(requireVerifiedAccount(), {
actor: {
user: {
uuid: 'u-1',
requires_email_confirmation: true,
email_confirmed: true,
requires_phone_verification: false,
requires_card_verification: false,
},
},
});
expect(got).toBeUndefined();
});
it("passes through when there's no actor (auth gate handled it)", () => {
const got = runGate(requireEmailConfirmedGate(), {});
const got = runGate(requireVerifiedAccount(), {});
expect(got).toBeUndefined();
});
});
+79 -14
View File
@@ -79,6 +79,16 @@ 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;
@@ -224,29 +234,84 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
};
/**
* Reject authenticated users whose account is pending email confirmation
* (`requires_email_confirmation && !email_confirmed`). Runs on every
* authenticated route by default; routes that set `allowUnconfirmed: true`
* skip this gate.
* Reject authenticated users whose account is still pending any signup-time
* verification — email confirmation, SMS phone verification, or credit-card
* verification. The abuse harness sets the phone/card flags on low-reputation
* signups (in place of a hard block), and this gate is what actually keeps
* those accounts out of the product until the flag clears: the flags live on
* `req.actor.user`, so every authenticated route enforces them, not just the
* GUI modal.
*
* Returns 403 with `email_confirmation_required` so clients can show the
* confirmation prompt instead of a generic error.
* Runs on every authenticated route by default; routes that set
* `allowUnconfirmed: true` opt out (the verification endpoints themselves,
* plus essential flows like whoami / logout / save-account so a pending
* account can still reach the screens that clear the gate).
*
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
* `phone_verification_required` / `card_verification_required`) so clients can
* show the right prompt instead of a generic error. There is no state where a
* user should be allowed in with one verification pending, so any pending gate
* rejects.
*/
export const requireEmailConfirmedGate = (): RequestHandler => {
export const requireVerifiedAccount = (): RequestHandler => {
return (req, _res, next) => {
const user = req.actor?.user;
if (user?.requires_email_confirmation && !user?.email_confirmed) {
next(
new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
}),
);
try {
assertVerifiedAccount(req.actor?.user);
} catch (err) {
next(err);
return;
}
next();
};
};
/**
* The pending-verification check, factored out of {@link requireVerifiedAccount}
* so auth paths that build their own actor outside the route-option machinery
* can enforce the exact same gate. The WebDAV controller is the motivating
* case: it dispatches every method off a single `router.use`, so
* `requireVerifiedAccount` is never wired into its chain — it has to call this
* directly. Keeping one implementation is the point: a verification gate added
* here is picked up by every caller, so the paths can't drift (which is how
* WebDAV came to bypass the phone/card gate to begin with).
*
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
* `phone_verification_required` / `card_verification_required`) so clients can
* show the right prompt instead of a generic error. There is no state where a
* user should be let in with any verification pending, so the first pending
* gate rejects.
*/
export const assertVerifiedAccount = (
user:
| {
requires_email_confirmation?: unknown;
email_confirmed?: unknown;
requires_phone_verification?: unknown;
requires_card_verification?: unknown;
}
| undefined,
): void => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
});
}
if (user?.requires_phone_verification) {
throw new HttpError(
403,
'Please verify your phone number to continue',
{
legacyCode: 'phone_verification_required' as never,
},
);
}
if (user?.requires_card_verification) {
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required' as never,
});
}
};
/**
* 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;
@@ -264,6 +264,79 @@ describe('createUserSubdomainRedirect', () => {
]);
});
const selfHosted = {
domain: 'puter.localhost',
static_hosting_domain: 'site.puter.localhost',
static_hosting_domain_alt: 'host.puter.localhost',
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: 'dev.puter.localhost',
} as IConfig;
it('still redirects a bare subdomain on the main domain to the hosting domain (self-hosted)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'foo'],
host: 'foo.puter.localhost',
originalUrl: '/bar?x=1',
protocol: 'http',
}),
);
expect(out.redirectArgs).toEqual([
302,
'http://foo.site.puter.localhost/bar?x=1',
]);
expect(next).not.toHaveBeenCalled();
});
it('passes through hosts already on the static hosting domain (no redirect loop)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'site', 'foo'],
host: 'foo.site.puter.localhost',
originalUrl: '/',
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it('passes through hosts on the alt / private-app hosting domains too', () => {
for (const host of [
'foo.host.puter.localhost',
'foo.app.puter.localhost',
'foo.dev.puter.localhost',
]) {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: [
'localhost',
'puter',
host.split('.')[1],
'foo',
],
host,
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
}
});
it('passes through the hosting-domain root itself (exact match, no loop)', () => {
const { out, next } = run(
createUserSubdomainRedirect(selfHosted),
makeReq({
subdomains: ['localhost', 'puter', 'site'],
host: 'site.puter.localhost',
}),
);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it("passes through when the request host has a port the configured domain doesn't", () => {
// Edge case worth pinning: the suffix check is exact-`endsWith`,
// so a port mismatch silently bypasses the redirect. Documenting
@@ -88,6 +88,15 @@ export const createUserSubdomainRedirect = (
if (!domain || !target) {
return (_req, _res, next) => next();
}
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
]
.map((d) => (d ?? '').toLowerCase().split(':')[0])
.filter((d) => d.length > 0);
return (req, res, next) => {
const active = (
req.subdomains?.[req.subdomains.length - 1] ?? ''
@@ -95,6 +104,14 @@ export const createUserSubdomainRedirect = (
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
const host = (req.headers.host ?? '').toLowerCase();
const hostName = host.split(':')[0];
if (
hostingDomains.some(
(d) => hostName === d || hostName.endsWith(`.${d}`),
)
) {
return next();
}
if (!host.endsWith(domain)) return next();
// host ends in domain — swap the domain suffix for the hosting one,
@@ -534,6 +534,51 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(piped!.equals(body)).toBe(true);
});
it('emits site.htmlServed with the original URL target including query string', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `serveq-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('<html>hi</html>'),
'text/html',
);
const emitSpy = vi.spyOn(server.clients.event, 'emit');
try {
const mw = buildMiddleware();
const { res } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/index.html',
originalUrl: '/index.html?brand=paypal',
}),
res,
vi.fn(),
);
const htmlServedCall = emitSpy.mock.calls.find(
(call) => call[0] === 'site.htmlServed',
);
expect(htmlServedCall?.[1]).toMatchObject({
subdomain: sub,
requestPath: '/index.html',
requestUrl: '/index.html?brand=paypal',
mime: 'text/html',
});
} finally {
emitSpy.mockRestore();
}
});
it('returns 206 status when a Range header is supplied', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
@@ -555,8 +555,12 @@ export const createPuterSiteMiddleware = (
res.setHeader('Content-Type', mime);
// Fire-and-forget signal for downstream extensions
if (mime === 'text/html' || mime === 'application/xhtml+xml') {
const mimeBase = mime.split(';', 1)[0].trim().toLowerCase();
if (mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml') {
try {
const requestUrl = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
layers.clients.event.emit(
'site.htmlServed',
{
@@ -564,7 +568,8 @@ export const createPuterSiteMiddleware = (
entry,
host,
requestPath: req.path,
mime,
requestUrl,
mime: mimeBase,
},
{},
);
+12 -1
View File
@@ -392,7 +392,14 @@ function ip(req) {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
function fingerprint(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
* exported so the global fingerprint middleware can stamp the identical value
* on `req.networkFingerprint` (one key space shared by both).
*/
export function computeNetworkFingerprint(req) {
const parts = [
ip(req),
req.headers?.['user-agent'] || '',
@@ -406,6 +413,10 @@ function fingerprint(req) {
.slice(0, 16);
}
function fingerprint(req) {
return computeNetworkFingerprint(req);
}
// -- Route middleware ------------------------------------------------
/**
@@ -26,6 +26,10 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import {
messagesHaveCompaction,
wantsCompaction,
} from '../../utils/compaction.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
@@ -141,6 +145,20 @@ export class AzureChatProvider implements IChatProvider {
}
return await this.#responsesProvider.complete(params);
}
// Inline compaction is a Responses-API feature; chat.completions can't
// express `context_management` or a `compaction` content block.
// Delegate to the Responses provider when the caller opted in OR when
// the messages carry a round-tripped compaction artifact.
if (wantsCompaction(params) || messagesHaveCompaction(messages)) {
if (!this.#responsesProvider) {
throw new HttpError(
400,
'compaction requires the OpenAI Responses provider, which is not configured',
{ legacyCode: 'bad_request' },
);
}
return await this.#responsesProvider.complete(params);
}
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
@@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import { toOpenAiContextManagement } from '../../utils/compaction.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
@@ -109,6 +110,8 @@ export class AzureResponsesProvider implements IChatProvider {
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
@@ -182,6 +185,13 @@ export class AzureResponsesProvider implements IChatProvider {
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
// Translate the neutral compaction opt-in (or pass a raw
// `context_management` payload through) to OpenAI's Responses shape.
const contextManagement = toOpenAiContextManagement({
compaction,
context_management,
});
const completionParams: ResponseCreateParams = {
user: userIdentifier,
safety_identifier: userIdentifier,
@@ -193,6 +203,9 @@ export class AzureResponsesProvider implements IChatProvider {
? { parallel_tool_calls }
: {}),
...(include !== undefined ? { include } : {}),
...(contextManagement !== undefined
? { context_management: contextManagement }
: {}),
...(conversation !== undefined ? { conversation } : {}),
...(previous_response_id !== undefined
? { previous_response_id }
@@ -486,6 +486,52 @@ describe('ClaudeProvider.complete non-stream output', () => {
10 * Number(haiku.costs.cache_read_input_tokens),
);
});
it('bills the compaction pass by summing usage.iterations', async () => {
const { provider } = makeProvider();
// Per Anthropic: top-level input/output reflect only the message pass;
// the compaction pass lives in `iterations` and must be summed to bill.
const msg = {
content: [{ type: 'text', text: 'done' }],
usage: {
input_tokens: 23000, // message pass only (NOT the total)
output_tokens: 1000,
iterations: [
{
type: 'compaction',
input_tokens: 180000,
output_tokens: 3500,
},
{ type: 'message', input_tokens: 23000, output_tokens: 1000 },
],
},
};
messagesCreateMock.mockResolvedValueOnce(msg);
const result = (await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'hi' }],
compaction: true,
}),
)) as { usage: Record<string, number> };
// Totals are the SUM across iterations, not the top-level fields.
expect(result.usage.input_tokens).toBe(203000); // 180000 + 23000
expect(result.usage.output_tokens).toBe(4500); // 3500 + 1000
const haiku = CLAUDE_MODELS.find(
(m) => m.id === 'claude-haiku-4-5-20251001',
)!;
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.input_tokens).toBe(203000);
expect(overrides.input_tokens).toBe(
203000 * Number(haiku.costs.input_tokens),
);
expect(overrides.output_tokens).toBe(
4500 * Number(haiku.costs.output_tokens),
);
});
});
// ── Streaming deltas ────────────────────────────────────────────────
@@ -618,6 +664,220 @@ describe('ClaudeProvider.complete streaming', () => {
});
});
// ── Inline compaction ───────────────────────────────────────────────
describe('ClaudeProvider.complete compaction', () => {
it('translates the neutral opt-in to context_management + the compaction beta', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'hi' }],
usage: { input_tokens: 1, output_tokens: 1 },
});
await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'hi' }],
compaction: { trigger_tokens: 50000 },
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
expect(args.context_management).toEqual({
edits: [
{
type: 'compact_20260112',
trigger: { type: 'input_tokens', value: 50000 },
},
],
});
expect(args.betas).toContain('compact-2026-01-12');
});
it('emits a canonical compaction event from a streamed compaction block', async () => {
const { provider } = makeProvider();
messagesStreamMock.mockReturnValueOnce(
makeStreamLike(
[
{ type: 'message_start' },
{
type: 'content_block_start',
content_block: {
type: 'compaction',
id: 'cmpct_1',
content: 'ENC', // Anthropic carries the summary here
},
},
{ type: 'content_block_stop' },
{
type: 'message_delta',
usage: { input_tokens: 1, output_tokens: 1 },
},
{ type: 'message_stop' },
],
{ input_tokens: 1, output_tokens: 1 },
),
);
const result = await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
compaction: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const compaction = harness
.events()
.find((e) => e.type === 'compaction');
expect(compaction).toEqual({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
});
it('enables the compaction beta when a round-tripped compaction block is resent (no opt-in)', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 1, output_tokens: 1 },
});
await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [
{ role: 'user', content: 'continue' },
{
role: 'assistant',
content: [
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
],
},
],
// note: no `compaction`/`context_management` opt-in
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
expect(args.betas).toContain('compact-2026-01-12');
// No new compaction was requested, so no context_management is sent.
expect(args.context_management).toBeUndefined();
});
it('surfaces a compaction block from a non-streaming response as result.compaction', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [
{ type: 'text', text: 'done' },
{
type: 'compaction',
id: 'cmpct_2',
content: 'ENC2', // Anthropic carries the summary here
},
],
usage: { input_tokens: 1, output_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'hi' }],
compaction: true,
}),
)) as { compaction?: { id?: string; encrypted_content: string } };
// Anthropic's `content` is surfaced under the unified `encrypted_content`.
expect(result.compaction).toEqual({
type: 'compaction',
id: 'cmpct_2',
encrypted_content: 'ENC2',
});
});
it('maps a round-tripped compaction block back to Anthropic `content` on input', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 1, output_tokens: 1 },
});
await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [
{ role: 'user', content: 'continue' },
{
role: 'assistant',
content: [
{ type: 'compaction', encrypted_content: 'SUMMARY' },
],
},
],
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
const compactionBlock = args.messages
.flatMap((m: { content?: unknown[] }) =>
Array.isArray(m.content) ? m.content : [],
)
.find((c: { type?: string }) => c?.type === 'compaction');
// Internal `encrypted_content` carrier → Anthropic native `content`.
expect(compactionBlock).toEqual({
type: 'compaction',
content: 'SUMMARY',
});
});
it('preserves the reply text when an assistant turn carries compaction + text', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 1, output_tokens: 1 },
});
await withTestActor(() =>
provider.complete({
model: 'claude-haiku-4-5-20251001',
messages: [
{
role: 'assistant',
content: [
{ type: 'compaction', encrypted_content: 'SUMMARY' },
{ type: 'text', text: 'earlier reply' },
],
},
{ role: 'user', content: 'continue' },
],
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
const blocks = args.messages.flatMap((m: { content?: unknown[] }) =>
Array.isArray(m.content) ? m.content : [],
);
// Compaction mapped to Anthropic `content`, AND the reply text kept.
expect(blocks).toContainEqual({
type: 'compaction',
content: 'SUMMARY',
});
expect(blocks).toContainEqual({ type: 'text', text: 'earlier reply' });
});
});
// ── Moderation ──────────────────────────────────────────────────────
describe('ClaudeProvider.checkModeration', () => {
@@ -34,6 +34,10 @@ import type {
ICompleteArguments,
IChatCompleteResult,
} from '../../types.js';
import {
messagesHaveCompaction,
toAnthropicContextManagement,
} from '../../utils/compaction.js';
import { make_claude_tools } from '../../utils/FunctionCalling.js';
import { extract_and_remove_system_messages } from '../../utils/Messages.js';
import type {
@@ -44,6 +48,11 @@ import type {
import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js';
import { CLAUDE_MODELS } from './models.js';
// Anthropic inline-compaction beta. The vendored SDK (0.68.0) doesn't type the
// `compact_20260112` edit or the `compaction` content block, so the request
// params and streamed/returned blocks are handled with `as any` casts.
const COMPACTION_BETA = 'compact-2026-01-12';
export class ClaudeProvider implements IChatProvider {
anthropic: Anthropic;
@@ -97,9 +106,18 @@ export class ClaudeProvider implements IChatProvider {
temperature,
reasoning,
reasoning_effort,
compaction,
context_management,
}: ICompleteArguments): Promise<IChatCompleteResult> {
tools = make_claude_tools(tools);
// Translate the neutral compaction opt-in (or pass a raw
// `context_management` payload through) to Anthropic's beta shape.
const contextManagement = toAnthropicContextManagement({
compaction,
context_management,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let system_prompts: string | any[];
[system_prompts, messages] =
@@ -218,6 +236,23 @@ export class ClaudeProvider implements IChatProvider {
return message;
});
// Map round-tripped compaction blocks back to Anthropic's native shape.
// The internal/unified carrier field is `encrypted_content`; Anthropic's
// compaction block uses `content` (a plaintext summary).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages = messages.map((message: any) => {
if (!Array.isArray(message.content)) return message;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
message.content = message.content.map((part: any) => {
if (part?.type !== 'compaction') return part;
return {
type: 'compaction',
content: part.content ?? part.encrypted_content ?? '',
};
});
return message;
});
const modelUsed =
this.models().find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
@@ -233,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);
@@ -243,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',
@@ -262,6 +299,18 @@ export class ClaudeProvider implements IChatProvider {
actor,
);
const usesBetaFiles = uploadedFileIds.length > 0;
// The compaction beta is needed both to *request* compaction
// (contextManagement) and to *accept a round-tripped* compaction block
// back as input (messagesHaveCompaction).
const usesCompaction =
!!contextManagement || messagesHaveCompaction(messages);
// Compaction and Files API both require the beta endpoint; combine their
// beta headers and route through `beta.messages.*` if either is active.
const betas = [
...(usesBetaFiles ? [FILES_API_BETA] : []),
...(usesCompaction ? [COMPACTION_BETA] : []),
];
const usesBeta = betas.length > 0;
const sdkParams: MessageCreateParams & {
betas?: string[];
@@ -291,7 +340,11 @@ export class ClaudeProvider implements IChatProvider {
...(supportsEffort && requestedReasoningEffort
? { output_config: { effort: requestedReasoningEffort } }
: {}),
...(usesBetaFiles ? { betas: [FILES_API_BETA] } : {}),
// Cast: `context_management` compaction edits aren't typed in SDK 0.68.0.
...(contextManagement
? { context_management: contextManagement as any }
: {}),
...(usesBeta ? { betas } : {}),
} as MessageCreateParams & { betas?: string[] };
const cleanupUploads = async () => {
@@ -315,13 +368,23 @@ export class ClaudeProvider implements IChatProvider {
}: {
chatStream: AIChatStream;
}) => {
const completion = usesBetaFiles
const completion = usesBeta
? this.anthropic.beta.messages.stream(sdkParams)
: this.anthropic.messages.stream(sdkParams);
const usageSum: Record<string, number> = {};
let message, contentBlock;
let currentContentBlockType: string | null = null;
// Inline-compaction block is an untyped beta block; capture its
// artifact across start/delta and emit on stop. Anthropic carries
// the summary in `content` (plaintext) — unlike OpenAI's
// `encrypted_content` — so read `content` first.
let compactionData: {
id?: string;
payload: string;
buffer: string;
} | null = null;
let emittedCompaction = false;
for await (const event of completion) {
if (event.type === 'message_delta') {
const meteredData = this.#usageFormatterUtil(
@@ -345,6 +408,24 @@ export class ClaudeProvider implements IChatProvider {
}
if (event.type === 'content_block_start') {
currentContentBlockType = event.content_block.type;
if (
(event.content_block.type as string) ===
'compaction'
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const block = event.content_block as any;
compactionData = {
id: block.id,
// Anthropic uses `content`; fall back to
// `encrypted_content` in case the field varies.
payload:
block.content ??
block.encrypted_content ??
'',
buffer: '',
};
continue;
}
if (event.content_block.type === 'tool_use') {
contentBlock = message!.contentBlock({
type: event.content_block.type,
@@ -363,12 +444,42 @@ export class ClaudeProvider implements IChatProvider {
continue;
}
if (event.type === 'content_block_stop') {
if (currentContentBlockType === 'compaction') {
const encrypted_content =
compactionData?.payload ||
compactionData?.buffer ||
'';
// Only emit (and mark done) if we actually captured
// the summary; otherwise let the finalMessage
// fallback recover it from the complete block.
if (encrypted_content) {
chatStream.compaction({
id: compactionData?.id,
encrypted_content,
});
emittedCompaction = true;
}
compactionData = null;
currentContentBlockType = null;
continue;
}
contentBlock!.end();
contentBlock = null;
currentContentBlockType = null;
continue;
}
if (event.type === 'content_block_delta') {
if (currentContentBlockType === 'compaction') {
// Capture any streamed payload for the artifact.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = event.delta as any;
const chunk =
d.partial_json ?? d.text ?? d.data ?? '';
if (typeof chunk === 'string' && compactionData) {
compactionData.buffer += chunk;
}
continue;
}
if (event.delta.type === 'input_json_delta') {
(contentBlock as AIChatToolUseStream)!.addPartialJSON(
event.delta.partial_json,
@@ -391,18 +502,37 @@ export class ClaudeProvider implements IChatProvider {
// signature_delta — ignored
}
}
const finalUsage = await completion
const finalMessage = await completion
.finalMessage()
.then((msg) =>
this.#usageFormatterUtil(
msg.usage as Usage | BetaUsage,
),
)
.catch(() => null);
if (finalUsage) {
if (finalMessage) {
const finalUsage = this.#usageFormatterUtil(
finalMessage.usage as Usage | BetaUsage,
);
for (const [key, value] of Object.entries(finalUsage)) {
usageSum[key] = value;
}
// Fallback: some SDK versions surface the compaction block
// only in the final message, not as a streamed block.
if (!emittedCompaction) {
const block = (
(finalMessage.content as unknown[]) ?? []
).find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c: any) => c?.type === 'compaction',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
if (block) {
chatStream.compaction({
id: block.id,
encrypted_content:
block.content ??
block.encrypted_content ??
'',
});
emittedCompaction = true;
}
}
}
chatStream.end(usageSum);
const costsOverrideFromModel =
@@ -423,7 +553,7 @@ export class ClaudeProvider implements IChatProvider {
}
try {
const msg = await (usesBetaFiles
const msg = await (usesBeta
? this.anthropic.beta.messages.create(sdkParams)
: this.anthropic.messages.create(sdkParams));
const usage = this.#usageFormatterUtil(
@@ -440,7 +570,37 @@ export class ClaudeProvider implements IChatProvider {
costsOverrideFromModel,
);
return { message: msg, usage, finish_reason: 'stop' };
// Surface any inline-compaction artifact for stateless round-trip.
const compactionBlock = (
((msg as Message).content as unknown[]) ?? []
).find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c: any) => c?.type === 'compaction',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
return {
message: msg,
usage,
finish_reason: 'stop',
...(compactionBlock
? {
compaction: {
// `type` makes the artifact a drop-in `messages`
// item for the round-trip (symmetric with the
// streaming compaction chunk).
type: 'compaction' as const,
...(compactionBlock.id !== undefined
? { id: compactionBlock.id }
: {}),
encrypted_content:
compactionBlock.content ??
compactionBlock.encrypted_content ??
'',
},
}
: {}),
};
} finally {
await cleanupUploads();
}
@@ -448,8 +608,31 @@ export class ClaudeProvider implements IChatProvider {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#usageFormatterUtil(usage: any) {
// Compaction responses report per-pass usage in `usage.iterations` (the
// compaction pass + the message pass). Per Anthropic's docs the
// top-level `input_tokens`/`output_tokens` reflect ONLY the
// non-compaction passes, so to bill the (often large) compaction pass we
// must sum across all iterations. Compaction is billed at normal token
// rates, and iterations don't break out cache fields — those stay
// top-level (the message pass's), which bills the compaction input at
// the full input rate.
const iterations = Array.isArray(usage?.iterations)
? usage.iterations
: null;
const inputTokens = iterations
? iterations.reduce(
(sum: number, it: any) => sum + (it?.input_tokens || 0),
0,
)
: usage?.input_tokens || 0;
const outputTokens = iterations
? iterations.reduce(
(sum: number, it: any) => sum + (it?.output_tokens || 0),
0,
)
: usage?.output_tokens || 0;
return {
input_tokens: usage?.input_tokens || 0,
input_tokens: inputTokens,
ephemeral_5m_input_tokens:
usage?.cache_creation?.ephemeral_5m_input_tokens ||
usage?.cache_creation_input_tokens ||
@@ -457,7 +640,7 @@ export class ClaudeProvider implements IChatProvider {
ephemeral_1h_input_tokens:
usage?.cache_creation?.ephemeral_1h_input_tokens || 0,
cache_read_input_tokens: usage?.cache_read_input_tokens || 0,
output_tokens: usage?.output_tokens || 0,
output_tokens: outputTokens,
thinking_tokens:
usage?.thinking_tokens ||
usage?.output_tokens_details?.thinking_tokens ||
@@ -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',
@@ -355,6 +355,8 @@ describe('GeminiChatProvider.complete non-stream output', () => {
prompt_tokens: 90,
completion_tokens: 50,
cached_tokens: 10,
thinking_tokens: 0,
grounding_requests: 0,
});
// gemini-2.5-flash costs: prompt=30, completion=250, cached=3.
@@ -367,11 +369,15 @@ describe('GeminiChatProvider.complete non-stream output', () => {
prompt_tokens: 90,
completion_tokens: 50,
cached_tokens: 10,
thinking_tokens: 0,
grounding_requests: 0,
});
expect(overrides).toEqual({
prompt_tokens: 90 * Number(flash.costs.prompt_tokens),
completion_tokens: 50 * Number(flash.costs.completion_tokens),
cached_tokens: 10 * Number(flash.costs.cached_tokens ?? 0),
thinking_tokens: 0,
grounding_requests: 0,
});
});
@@ -446,6 +452,8 @@ describe('GeminiChatProvider.complete streaming', () => {
prompt_tokens: 3, // 4 - 1 cached
completion_tokens: 2,
cached_tokens: 1,
thinking_tokens: 0,
grounding_requests: 0,
});
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
@@ -456,10 +464,192 @@ describe('GeminiChatProvider.complete streaming', () => {
prompt_tokens: 3 * Number(flash.costs.prompt_tokens),
completion_tokens: 2 * Number(flash.costs.completion_tokens),
cached_tokens: 1 * Number(flash.costs.cached_tokens ?? 0),
thinking_tokens: 0,
grounding_requests: 0,
});
});
});
// ── Thinking-token metering ─────────────────────────────────────────
describe('GeminiChatProvider.complete thinking token metering', () => {
it('splits thinking tokens out of completion_tokens and bills each at its own rate', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'result', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 60,
completion_tokens_details: { reasoning_tokens: 20 },
prompt_tokens_details: { cached_tokens: 10 },
},
});
const result = await withTestActor(() =>
provider.complete({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: 'think hard' }],
}),
);
expect((result as { usage: unknown }).usage).toEqual({
prompt_tokens: 90, // 100 - 10 cached
completion_tokens: 40, // 60 - 20 thinking
cached_tokens: 10,
thinking_tokens: 20,
grounding_requests: 0,
});
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.thinking_tokens).toBe(20);
expect(usage.completion_tokens).toBe(40);
expect(overrides).toMatchObject({
thinking_tokens: 20 * Number(flash.costs.thinking_tokens),
completion_tokens: 40 * Number(flash.costs.completion_tokens),
});
});
it('sets thinking_tokens to 0 when completion_tokens_details is absent', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 5, completion_tokens: 3 },
});
await withTestActor(() =>
provider.complete({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage] = recordSpy.mock.calls[0]!;
expect(usage.thinking_tokens).toBe(0);
expect(usage.completion_tokens).toBe(3);
});
});
// ── Grounding-request metering ───────────────────────────────────────
describe('GeminiChatProvider.complete grounding request metering', () => {
it('charges one grounding request when non-stream response contains grounding_metadata', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
content: 'result',
role: 'assistant',
extra_content: {
grounding_metadata: { web_search_queries: ['foo'] },
},
},
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5 },
});
await withTestActor(() =>
provider.complete({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: 'search for foo' }],
}),
);
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.grounding_requests).toBe(1);
expect(overrides!.grounding_requests).toBe(
1 * Number(flash.costs.grounding_requests),
);
});
it('does not charge a grounding request when no grounding_metadata is present', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 5, completion_tokens: 3 },
});
await withTestActor(() =>
provider.complete({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage] = recordSpy.mock.calls[0]!;
expect(usage.grounding_requests).toBe(0);
});
it('charges one grounding request when streaming response contains grounding extra_content', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'ans' } }] },
{
choices: [
{
delta: {
extra_content: {
grounding_metadata: {
web_search_queries: ['bar'],
},
},
},
},
],
},
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 8, completion_tokens: 4 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'gemini-2.5-flash',
messages: [{ role: 'user', content: 'search bar' }],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.grounding_requests).toBe(1);
expect(overrides!.grounding_requests).toBe(
1 * Number(flash.costs.grounding_requests),
);
});
});
// ── Error mapping ───────────────────────────────────────────────────
describe('GeminiChatProvider.complete error mapping', () => {
@@ -102,24 +102,52 @@ export class GeminiChatProvider implements IChatProvider {
}
return handle_completion_output({
usage_calculator: ({ usage }) => {
usage_calculator: (args) => {
// Cast to access Gemini-specific extras passed alongside usage:
// - choices: non-stream grounding metadata lives in choices[0].message.extra_content
// - extra_content: streaming grounding metadata accumulated by the stream handler
const { usage, choices, extra_content } = args as {
usage: typeof args.usage;
choices?: Array<{
message?: {
extra_content?: { grounding_metadata?: unknown };
};
}>;
extra_content?: { grounding_metadata?: unknown };
};
const cached_tokens =
usage?.prompt_tokens_details?.cached_tokens ?? 0;
// Thinking tokens are a subset of completion_tokens billed at a different rate
const thinking_tokens =
usage?.completion_tokens_details?.reasoning_tokens ?? 0;
const trackedUsage = {
prompt_tokens:
(usage.prompt_tokens ?? 0) -
(usage.prompt_tokens_details?.cached_tokens ?? 0),
completion_tokens: usage.completion_tokens ?? 0,
cached_tokens:
usage.prompt_tokens_details?.cached_tokens ?? 0,
prompt_tokens: (usage?.prompt_tokens ?? 0) - cached_tokens,
completion_tokens: Math.max(
0,
(usage?.completion_tokens ?? 0) - thinking_tokens,
),
cached_tokens,
thinking_tokens,
// Grounding search is a per-request fee not reflected in token counts
grounding_requests:
(choices?.[0]?.message?.extra_content
?.grounding_metadata ??
extra_content?.grounding_metadata)
? 1
: 0,
};
const costsOverrideFromModel = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
return [k, v * (modelUsed.costs[k] ?? 0)];
}),
);
this.meteringService.utilRecordUsageObject(
trackedUsage,
actor,
actor!,
`gemini:${modelUsed?.id}`,
costsOverrideFromModel,
);
@@ -43,7 +43,10 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 150,
completion_tokens: 900,
thinking_tokens: 900,
cached_tokens: 15,
// Gemini 3.x grounding is $14 / 1,000 requests
grounding_requests: 1_400_000,
},
},
{
@@ -68,6 +71,8 @@ export const GEMINI_MODELS: IChatModel[] = [
prompt_tokens: 10,
completion_tokens: 40,
cached_tokens: 3,
// Gemini 2.x grounding is $35 / 1,000 requests
grounding_requests: 3_500_000,
},
max_tokens: 8192,
},
@@ -115,8 +120,13 @@ export const GEMINI_MODELS: IChatModel[] = [
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
// Output is $2.50/M; thinking tokens bill at the same output rate
completion_tokens: 250,
thinking_tokens: 250,
// Cache read is $0.03/M (10% of input)
cached_tokens: 3,
// Gemini 2.x grounding is $35 / 1,000 requests
grounding_requests: 3_500_000,
},
max_tokens: 65536,
},
@@ -141,7 +151,10 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 40,
thinking_tokens: 40,
cached_tokens: 1,
// Gemini 2.x grounding is $35 / 1,000 requests
grounding_requests: 3_500_000,
},
max_tokens: 65536,
},
@@ -166,7 +179,10 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 125,
completion_tokens: 1000,
cached_tokens: 13,
thinking_tokens: 1000,
cached_tokens: 31,
// Gemini 2.x grounding is $35 / 1,000 requests
grounding_requests: 3_500_000,
},
max_tokens: 200_000,
},
@@ -191,7 +207,9 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 1200,
thinking_tokens: 1200,
cached_tokens: 20,
grounding_requests: 1_400_000,
},
max_tokens: 65536,
},
@@ -216,7 +234,9 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 300,
thinking_tokens: 300,
cached_tokens: 5,
grounding_requests: 1_400_000,
},
max_tokens: 65536,
},
@@ -245,7 +265,9 @@ export const GEMINI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 25,
completion_tokens: 150,
thinking_tokens: 150,
cached_tokens: 2.5,
grounding_requests: 1_400_000,
},
max_tokens: 65536,
},
@@ -143,6 +143,11 @@ describe('MiniMaxProvider model catalog', () => {
it('uses MiniMax completion-token limits instead of the context window', () => {
for (const model of MINIMAX_MODELS) {
if (model.id === 'minimax-m3') {
expect(model.context).toBe(1_048_576);
expect(model.max_tokens).toBe(512_000);
continue;
}
expect(model.context).toBe(204_800);
expect(model.max_tokens).toBe(196_608);
}
@@ -18,60 +18,215 @@
*/
import type { IChatModel } from '../../types.js';
import { usdPerMToken } from '../../utils/pricing.js';
const CONTEXT_WINDOW = 204_800;
const MAX_OUTPUT_TOKENS = 196_608;
type MiniMaxChatModel = IChatModel & {
apiModel: string;
};
const minimaxModel = (
apiModel: string,
name: string,
costs: IChatModel['costs'],
): MiniMaxChatModel => {
const id = apiModel.toLowerCase();
return {
puterId: `minimax:minimax/${id}`,
id,
apiModel,
name,
aliases: [`minimax/${id}`, apiModel, `minimax/${apiModel}`],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: CONTEXT_WINDOW,
max_tokens: MAX_OUTPUT_TOKENS,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs,
};
};
// Hardcoded from MiniMax OpenAI-compatible API docs and pay-as-you-go pricing:
// https://platform.minimax.io/docs/api-reference/text-openai-api
// https://platform.minimax.io/docs/guides/pricing-paygo
export const MINIMAX_MODELS: MiniMaxChatModel[] = [
minimaxModel('MiniMax-M2.7', 'MiniMax M2.7', usdPerMToken(0.3, 1.2, 0.06)),
minimaxModel(
'MiniMax-M2.7-highspeed',
'MiniMax M2.7 Highspeed',
usdPerMToken(0.6, 2.4, 0.06),
),
minimaxModel('MiniMax-M2.5', 'MiniMax M2.5', usdPerMToken(0.3, 1.2, 0.03)),
minimaxModel(
'MiniMax-M2.5-highspeed',
'MiniMax M2.5 Highspeed',
usdPerMToken(0.6, 2.4, 0.03),
),
minimaxModel('MiniMax-M2.1', 'MiniMax M2.1', usdPerMToken(0.3, 1.2, 0.03)),
minimaxModel(
'MiniMax-M2.1-highspeed',
'MiniMax M2.1 Highspeed',
usdPerMToken(0.6, 2.4, 0.03),
),
minimaxModel('MiniMax-M2', 'MiniMax M2', usdPerMToken(0.3, 1.2, 0.03)),
// -- MiniMax M3 (Flagship) --------------------------------------
// MiniMax Sparse Attention, 1M context, native multimodal input.
{
puterId: 'minimax:minimax/minimax-m3',
id: 'minimax-m3',
apiModel: 'MiniMax-M3',
name: 'MiniMax M3',
aliases: ['minimax/minimax-m3', 'MiniMax-M3', 'minimax/MiniMax-M3'],
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 1_048_576,
max_tokens: 512_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M (input <= 512k tokens)
completion_tokens: 120, // $1.20 per 1M
cached_tokens: 6, // $0.06 per 1M
},
},
// -- MiniMax M2.7 -----------------------------------------------
{
puterId: 'minimax:minimax/minimax-m2.7',
id: 'minimax-m2.7',
apiModel: 'MiniMax-M2.7',
name: 'MiniMax M2.7',
aliases: [
'minimax/minimax-m2.7',
'MiniMax-M2.7',
'minimax/MiniMax-M2.7',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
completion_tokens: 120, // $1.20 per 1M
cached_tokens: 6, // $0.06 per 1M
},
},
{
puterId: 'minimax:minimax/minimax-m2.7-highspeed',
id: 'minimax-m2.7-highspeed',
apiModel: 'MiniMax-M2.7-highspeed',
name: 'MiniMax M2.7 Highspeed',
aliases: [
'minimax/minimax-m2.7-highspeed',
'MiniMax-M2.7-highspeed',
'minimax/MiniMax-M2.7-highspeed',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 60, // $0.60 per 1M
completion_tokens: 240, // $2.40 per 1M
cached_tokens: 6, // $0.06 per 1M
},
},
// -- MiniMax M2.5 -----------------------------------------------
{
puterId: 'minimax:minimax/minimax-m2.5',
id: 'minimax-m2.5',
apiModel: 'MiniMax-M2.5',
name: 'MiniMax M2.5',
aliases: [
'minimax/minimax-m2.5',
'MiniMax-M2.5',
'minimax/MiniMax-M2.5',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
completion_tokens: 120, // $1.20 per 1M
cached_tokens: 3, // $0.03 per 1M
},
},
{
puterId: 'minimax:minimax/minimax-m2.5-highspeed',
id: 'minimax-m2.5-highspeed',
apiModel: 'MiniMax-M2.5-highspeed',
name: 'MiniMax M2.5 Highspeed',
aliases: [
'minimax/minimax-m2.5-highspeed',
'MiniMax-M2.5-highspeed',
'minimax/MiniMax-M2.5-highspeed',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 60, // $0.60 per 1M
completion_tokens: 240, // $2.40 per 1M
cached_tokens: 3, // $0.03 per 1M
},
},
// -- MiniMax M2.1 -----------------------------------------------
{
puterId: 'minimax:minimax/minimax-m2.1',
id: 'minimax-m2.1',
apiModel: 'MiniMax-M2.1',
name: 'MiniMax M2.1',
aliases: [
'minimax/minimax-m2.1',
'MiniMax-M2.1',
'minimax/MiniMax-M2.1',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
completion_tokens: 120, // $1.20 per 1M
cached_tokens: 3, // $0.03 per 1M
},
},
{
puterId: 'minimax:minimax/minimax-m2.1-highspeed',
id: 'minimax-m2.1-highspeed',
apiModel: 'MiniMax-M2.1-highspeed',
name: 'MiniMax M2.1 Highspeed',
aliases: [
'minimax/minimax-m2.1-highspeed',
'MiniMax-M2.1-highspeed',
'minimax/MiniMax-M2.1-highspeed',
],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 60, // $0.60 per 1M
completion_tokens: 240, // $2.40 per 1M
cached_tokens: 3, // $0.03 per 1M
},
},
// -- MiniMax M2 -------------------------------------------------
{
puterId: 'minimax:minimax/minimax-m2',
id: 'minimax-m2',
apiModel: 'MiniMax-M2',
name: 'MiniMax M2',
aliases: ['minimax/minimax-m2', 'MiniMax-M2', 'minimax/MiniMax-M2'],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: 204_800,
max_tokens: 196_608,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
completion_tokens: 120, // $1.20 per 1M
cached_tokens: 3, // $0.03 per 1M
},
},
];
@@ -26,6 +26,10 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import {
messagesHaveCompaction,
wantsCompaction,
} from '../../utils/compaction.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
@@ -125,6 +129,20 @@ export class OpenAiChatProvider implements IChatProvider {
}
return await this.#responsesProvider.complete(params);
}
// Inline compaction is a Responses-API feature; chat.completions can't
// express `context_management` or a `compaction` content block.
// Delegate to the sibling Responses provider when the caller opted in
// OR when the messages carry a round-tripped compaction artifact.
if (wantsCompaction(params) || messagesHaveCompaction(messages)) {
if (!this.#responsesProvider) {
throw new HttpError(
400,
'compaction requires the OpenAI Responses provider, which is not configured',
{ legacyCode: 'bad_request' },
);
}
return await this.#responsesProvider.complete(params);
}
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
@@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import { toOpenAiContextManagement } from '../../utils/compaction.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
@@ -100,6 +101,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
@@ -177,6 +180,13 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
// Translate the neutral compaction opt-in (or pass a raw
// `context_management` payload through) to OpenAI's Responses shape.
const contextManagement = toOpenAiContextManagement({
compaction,
context_management,
});
const completionParams: ResponseCreateParams = {
user: userIdentifier,
safety_identifier: userIdentifier,
@@ -188,6 +198,9 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
? { parallel_tool_calls }
: {}),
...(include !== undefined ? { include } : {}),
...(contextManagement !== undefined
? { context_management: contextManagement }
: {}),
...(conversation !== undefined ? { conversation } : {}),
...(previous_response_id !== undefined
? { previous_response_id }
@@ -150,21 +150,7 @@ export class TogetherAIProvider implements IChatProvider {
messages,
stream,
...(tools ? { tools } : {}),
// TODO: make this better but togetherai doesn't handle max tokens properly at all
...(max_tokens
? {
max_tokens:
max_tokens -
messages.reduce((acc, curr) => {
return (
acc +
(curr.type === 'text'
? curr.text.length / 2
: 200)
);
}, 0),
}
: {}),
...(max_tokens ? { max_tokens } : {}),
...(temperature ? { temperature } : {}),
...(stream ? { stream_options: { include_usage: true } } : {}),
} as Together.Chat.Completions.CompletionCreateParamsNonStreaming);
@@ -69,6 +69,13 @@ const visionModel = (
// Hardcoded from https://docs.z.ai/api-reference/llm/chat-completion and
// https://docs.z.ai/guides/overview/pricing.
export const ZAI_MODELS: IChatModel[] = [
textModel(
'glm-5.2',
'GLM-5.2',
1_000 * K,
128 * K,
usdPerMToken(1.4, 4.4, 0.26),
),
textModel(
'glm-5.1',
'GLM-5.1',
+20
View File
@@ -67,6 +67,19 @@ export interface ICompleteArguments {
parallel_tool_calls?: boolean;
include?: unknown[];
conversation?: unknown;
/**
* Provider-neutral inline-compaction opt-in. `true` enables compaction with
* provider defaults; `{ trigger_tokens }` sets the token threshold at which
* the upstream summarizes earlier context. Each provider translates this to
* its own SDK shape (OpenAI `context_management:[{type:'compaction',...}]`,
* Anthropic `context_management:{edits:[{type:'compact_20260112'}]}`).
*/
compaction?: boolean | { trigger_tokens?: number };
/**
* Escape hatch: provider-native `context_management` payload, passed through
* untouched (used by `/responses` callers sending the OpenAI-native array).
*/
context_management?: unknown;
previous_response_id?: string;
instructions?: string | PuterMessage[];
metadata?: Record<string, string>;
@@ -116,6 +129,13 @@ export interface IChatMessageResult {
finally_fn?: never;
normalized?: boolean;
via_ai_chat_service?: boolean;
/**
* Inline-compaction artifact, present when the upstream compacted earlier
* context during this (non-streaming) response. Carries `type:'compaction'`
* so it's a drop-in `messages` item — the caller resends it on the next turn
* in place of the summarized history. See [[ICompleteArguments]].
*/
compaction?: { type: 'compaction'; id?: string; encrypted_content: string };
}
export type IChatCompleteResult = IChatStreamResult | IChatMessageResult;
@@ -49,6 +49,23 @@ export const normalize_single_message = (message, params = {}) => {
legacyCode: 'bad_request',
});
}
// A round-tripped inline-compaction artifact may be supplied as a bare
// top-level item (the shape the client received from the stream). Wrap it
// into an internal compaction content block so it survives normalization;
// each provider maps it back to its native input shape (OpenAI top-level
// input item, Anthropic content block).
if (!message.role && !message.content && message.type === 'compaction') {
return {
role: 'assistant',
content: [
{
type: 'compaction',
...(message.id !== undefined ? { id: message.id } : {}),
encrypted_content: message.encrypted_content,
},
],
};
}
if (!message.role) {
message.role = params.role;
}
@@ -46,6 +46,22 @@ describe('normalize_single_message', () => {
expect(result.role).toBe('system');
});
it('wraps a bare round-tripped compaction item into a compaction content block', () => {
const result = normalize_single_message({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
expect(result.role).toBe('assistant');
expect(result.content).toEqual([
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
]);
});
it('throws 400 when message is null/undefined/array', () => {
expect(() => normalize_single_message(null)).toThrow(
expect.objectContaining({ statusCode: 400 }),
@@ -84,6 +84,39 @@ export const process_input_messages = async (messages) => {
};
export const process_input_messages_responses_api = async (messages) => {
// Pre-split round-tripped compaction blocks into standalone Responses
// compaction input items, preserving any sibling content (e.g. the
// assistant's reply text) as its own message. A compaction item represents
// prior history, so it precedes the message it was attached to. This avoids
// collapsing the whole message into a single compaction item and dropping
// the rest of its content.
const expanded = [];
for (const msg of messages) {
if (msg && Array.isArray(msg.content)) {
const compactionBlocks = msg.content.filter(
(c) => c && c.type === 'compaction',
);
if (compactionBlocks.length > 0) {
for (const block of compactionBlocks) {
expanded.push({
type: 'compaction',
...(block.id !== undefined ? { id: block.id } : {}),
encrypted_content: block.encrypted_content,
});
}
const rest = msg.content.filter(
(c) => !(c && c.type === 'compaction'),
);
if (rest.length > 0) {
expanded.push({ ...msg, content: rest });
}
continue;
}
}
expanded.push(msg);
}
messages = expanded;
for (const msg of messages) {
const content_as_string = (content) => {
if (content === undefined || content === null) return '';
@@ -253,6 +286,7 @@ export const create_chat_stream_handler =
const tool_call_blocks = [];
let last_usage = null;
let last_extra_content = null;
for await (let chunk of completion) {
chunk = deviations.chunk_but_like_actually(chunk);
const chunk_usage = deviations.index_usage_from_stream_chunk(chunk);
@@ -285,6 +319,14 @@ export const create_chat_stream_handler =
// Apps have to choose to handle extra_content themselves, it doesn't seem like theres a way we can do it in a backwards
// compatible fashion since most streaming apps will handle chat history by continuously updating content themselves
// This doesn't present us a chance to add in an extra object for gemini's chat continuing features
// Don't let a later extra_content chunk without grounding clobber an
// earlier one that carried grounding_metadata (used for metering).
if (
choice.delta.extra_content.grounding_metadata ||
!last_extra_content?.grounding_metadata
) {
last_extra_content = choice.delta.extra_content;
}
textblock.addExtraContent(choice.delta.extra_content);
}
@@ -315,7 +357,10 @@ export const create_chat_stream_handler =
}
// TODO DS: this is a bit too abstracted... this is basically just doing the metering now
const usage = usage_calculator({ usage: last_usage });
const usage = usage_calculator({
usage: last_usage,
extra_content: last_extra_content,
});
if (mode === 'text') textblock.end();
if (mode === 'tool') toolblock.end();
@@ -355,6 +400,19 @@ export const create_chat_stream_handler_responses_api =
last_usage = chunk.response.usage;
}
if (
chunk.type === 'response.output_item.done' &&
chunk.item?.type === 'compaction'
) {
// Inline compaction fired mid-response — normalize the artifact
// into the canonical internal compaction event.
chatStream.compaction({
id: chunk.item.id,
encrypted_content: chunk.item.encrypted_content,
});
continue;
}
if (
chunk.type === 'response.output_item.done' &&
chunk.item?.type === 'function_call'
@@ -488,10 +546,14 @@ export const handle_completion_output_responses_api = async ({
...(item.id ? { canonical_id: item.id } : {}),
}));
// Inline-compaction artifact, if the upstream compacted this turn.
const compactionItem = output.find((item) => item?.type === 'compaction');
const is_empty = completion.output_text.trim() === '';
if (is_empty && responseToolCalls.length < 1) {
if (is_empty && responseToolCalls.length < 1 && !compactionItem) {
// GPT refuses to generate an empty response if you ask it to,
// so this will probably only happen on an error condition.
// A compaction-only output is legitimate, so don't reject it.
throw new HttpError(400, 'an empty response was generated', {
legacyCode: 'bad_response',
});
@@ -523,6 +585,18 @@ export const handle_completion_output_responses_api = async ({
};
ret.role = output.find((item) => item?.role)?.role ?? 'assistant';
if (compactionItem) {
// Include `type` so the artifact is a drop-in `messages` item for the
// stateless round-trip — symmetric with the streaming compaction chunk.
ret.compaction = {
type: 'compaction',
...(compactionItem.id !== undefined
? { id: compactionItem.id }
: {}),
encrypted_content: compactionItem.encrypted_content,
};
}
delete ret.type;
ret.usage = usage_calculator
@@ -232,6 +232,66 @@ describe('process_input_messages_responses_api', () => {
expect(out!.output).toBe('part-apart-bpart-c');
});
it('rewrites a round-tripped compaction content block into a top-level compaction item', async () => {
const messages: Array<Record<string, unknown>> = [
{
role: 'assistant',
content: [
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
],
},
];
const [out] = (await process_input_messages_responses_api(
messages,
)) as Array<Record<string, unknown>>;
expect(out!.type).toBe('compaction');
expect(out!.encrypted_content).toBe('ENC');
expect(out!.id).toBe('cmpct_1');
expect(out!.role).toBeUndefined();
expect(out!.content).toBeUndefined();
});
it('splits a compaction block + reply text into a compaction item plus a preserved message', async () => {
// Round-tripping an assistant turn that carries BOTH the compaction
// artifact and the reply text must keep the reply, not drop it.
const messages: Array<Record<string, unknown>> = [
{
role: 'assistant',
content: [
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
{ type: 'text', text: 'earlier reply' },
],
},
];
const out = (await process_input_messages_responses_api(
messages,
)) as Array<Record<string, unknown>>;
// Compaction item comes first (it represents prior history)...
expect(out).toHaveLength(2);
expect(out[0]).toEqual({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
// ...followed by the assistant message with the reply preserved
// (assistant text upgraded to output_text).
expect(out[1]!.role).toBe('assistant');
expect(out[1]!.content).toEqual([
{ type: 'output_text', text: 'earlier reply' },
]);
});
it('upgrades user/system text blocks to input_text', async () => {
const messages: Array<Record<string, unknown>> = [
{
@@ -539,6 +599,39 @@ describe('create_chat_stream_handler_responses_api', () => {
});
});
it('emits a compaction event when a compaction output_item completes', async () => {
const completion = asAsyncIterable([
{
type: 'response.output_item.done',
item: {
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
},
{
type: 'response.completed',
response: { usage: { input_tokens: 1, output_tokens: 2 } },
},
]);
const init = create_chat_stream_handler_responses_api({
deviations: undefined,
completion,
usage_calculator: () => ({}),
});
const harness = makeCapturingChatStream();
await init({ chatStream: harness.chatStream });
const compaction = harness
.events()
.find((e: { type: string }) => e.type === 'compaction');
expect(compaction).toEqual({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
});
it('emits a tool_use block when a function_call output_item completes', async () => {
const completion = asAsyncIterable([
{
@@ -765,6 +858,31 @@ describe('handle_completion_output_responses_api non-stream', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('attaches a compaction artifact and allows a compaction-only output', async () => {
const completion = {
output: [
{
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
},
],
output_text: ' ',
usage: { input_tokens: 1, output_tokens: 0 },
};
const result = await handle_completion_output_responses_api({
deviations: undefined,
stream: false,
completion,
});
// Compaction-only output is not rejected as "empty".
expect(result.compaction).toEqual({
type: 'compaction',
id: 'cmpct_1',
encrypted_content: 'ENC',
});
});
it('runs moderation against output_text when a moderate fn is supplied', async () => {
const completion = {
output: [{ role: 'assistant' }],
@@ -103,6 +103,25 @@ export class AIChatStream {
this.stream.end();
}
/**
* Emit a canonical compaction event into the NDJSON stream. Both the OpenAI
* and Anthropic providers normalize their native inline-compaction artifact
* to this single shape, so downstream consumers (controllers, puter.js) see
* an identical `{ type: 'compaction', id, encrypted_content }` chunk
* regardless of which upstream served the request.
*
* @param {{ id?: string, encrypted_content: string }} compaction
*/
compaction({ id, encrypted_content }) {
this.stream.write(
`${JSON.stringify({
type: 'compaction',
...(id !== undefined ? { id } : {}),
encrypted_content,
})}\n`,
);
}
message() {
return new AIChatMessageStream(this);
}
@@ -0,0 +1,124 @@
/*
* 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/>.
*/
/**
* Inline-compaction translation helpers.
*
* The driver-facing surface exposes a single provider-neutral opt-in
* (`compaction: boolean | { trigger_tokens }` on `ICompleteArguments`), plus a
* raw `context_management` escape hatch for callers hitting `/responses` with
* the OpenAI-native shape. These helpers map that neutral opt-in to each
* provider's SDK shape so the providers stay free of opt-in-parsing logic.
*/
/**
* @param {boolean | { trigger_tokens?: number } | undefined} compaction
* @returns {{ enabled: boolean, trigger_tokens?: number }}
*/
const readCompaction = (compaction) => {
if (compaction === true) return { enabled: true };
if (compaction && typeof compaction === 'object') {
return {
enabled: true,
...(typeof compaction.trigger_tokens === 'number'
? { trigger_tokens: compaction.trigger_tokens }
: {}),
};
}
return { enabled: false };
};
/**
* Build OpenAI Responses `context_management` from the neutral opt-in. A raw
* `context_management` passthrough (already in OpenAI shape) wins.
*
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
* @returns {Array<{ type: 'compaction', compact_threshold?: number }> | undefined}
*/
export const toOpenAiContextManagement = (args) => {
if (args.context_management !== undefined) {
return /** @type {any} */ (args.context_management);
}
const { enabled, trigger_tokens } = readCompaction(args.compaction);
if (!enabled) return undefined;
return [
{
type: 'compaction',
...(trigger_tokens !== undefined
? { compact_threshold: trigger_tokens }
: {}),
},
];
};
/**
* Build Anthropic `context_management` (beta `compact-2026-01-12`) from the
* neutral opt-in. A raw `context_management` passthrough wins.
*
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
* @returns {{ edits: Array<Record<string, unknown>> } | undefined}
*/
export const toAnthropicContextManagement = (args) => {
if (args.context_management !== undefined) {
return /** @type {any} */ (args.context_management);
}
const { enabled, trigger_tokens } = readCompaction(args.compaction);
if (!enabled) return undefined;
return {
edits: [
{
type: 'compact_20260112',
...(trigger_tokens !== undefined
? {
trigger: {
type: 'input_tokens',
value: trigger_tokens,
},
}
: {}),
},
],
};
};
/**
* Whether the request opted into inline compaction by any route.
*
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
*/
export const wantsCompaction = (args) =>
args.context_management !== undefined ||
readCompaction(args.compaction).enabled;
/**
* Whether the (normalized) message list carries a round-tripped compaction
* artifact. Such a request must route through a compaction-capable surface even
* if it didn't request *new* compaction — chat.completions can't represent a
* compaction content block, and Anthropic needs its compaction beta to accept
* one as input.
*
* @param {unknown} messages
*/
export const messagesHaveCompaction = (messages) =>
Array.isArray(messages) &&
messages.some(
(m) =>
Array.isArray(m?.content) &&
m.content.some((c) => c && c.type === 'compaction'),
);
@@ -0,0 +1,132 @@
/*
* 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 {
messagesHaveCompaction,
toAnthropicContextManagement,
toOpenAiContextManagement,
wantsCompaction,
} from './compaction.js';
describe('toOpenAiContextManagement', () => {
it('returns undefined when compaction is off', () => {
expect(toOpenAiContextManagement({})).toBeUndefined();
expect(
toOpenAiContextManagement({ compaction: false }),
).toBeUndefined();
});
it('builds a compaction entry from `true`', () => {
expect(toOpenAiContextManagement({ compaction: true })).toEqual([
{ type: 'compaction' },
]);
});
it('maps trigger_tokens to compact_threshold', () => {
expect(
toOpenAiContextManagement({ compaction: { trigger_tokens: 1000 } }),
).toEqual([{ type: 'compaction', compact_threshold: 1000 }]);
});
it('passes a raw context_management payload through verbatim', () => {
const raw = [{ type: 'compaction', compact_threshold: 5 }];
expect(
toOpenAiContextManagement({
compaction: true,
context_management: raw,
}),
).toBe(raw);
});
});
describe('toAnthropicContextManagement', () => {
it('returns undefined when compaction is off', () => {
expect(toAnthropicContextManagement({})).toBeUndefined();
});
it('builds a compact_20260112 edit from `true`', () => {
expect(toAnthropicContextManagement({ compaction: true })).toEqual({
edits: [{ type: 'compact_20260112' }],
});
});
it('maps trigger_tokens to an input_tokens trigger', () => {
expect(
toAnthropicContextManagement({
compaction: { trigger_tokens: 2000 },
}),
).toEqual({
edits: [
{
type: 'compact_20260112',
trigger: { type: 'input_tokens', value: 2000 },
},
],
});
});
it('passes a raw context_management payload through verbatim', () => {
const raw = { edits: [{ type: 'compact_20260112' }] };
expect(
toAnthropicContextManagement({ context_management: raw }),
).toBe(raw);
});
});
describe('wantsCompaction', () => {
it('is false without opt-in', () => {
expect(wantsCompaction({})).toBe(false);
expect(wantsCompaction({ compaction: false })).toBe(false);
});
it('is true for the neutral opt-in or a raw passthrough', () => {
expect(wantsCompaction({ compaction: true })).toBe(true);
expect(wantsCompaction({ compaction: { trigger_tokens: 1 } })).toBe(
true,
);
expect(wantsCompaction({ context_management: [] })).toBe(true);
});
});
describe('messagesHaveCompaction', () => {
it('detects a round-tripped compaction content block', () => {
expect(
messagesHaveCompaction([
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
{
role: 'assistant',
content: [
{ type: 'compaction', encrypted_content: 'ENC' },
],
},
]),
).toBe(true);
});
it('is false for ordinary messages or non-arrays', () => {
expect(
messagesHaveCompaction([
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
]),
).toBe(false);
expect(messagesHaveCompaction(undefined)).toBe(false);
expect(messagesHaveCompaction('nope')).toBe(false);
});
});
@@ -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:
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more