Compare commits

...
62 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
144 changed files with 9968 additions and 6310 deletions

No files matched your search

+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": {
+10 -1
View File
@@ -135,7 +135,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
Why these knobs:
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
@@ -379,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
`difficulty` is one of `easy` / `medium` / `hard`.
### Disable new signups
Force visitors to log in with an existing account instead of creating a
temporary or permanent one.
```json
"disable_user_signup": true
```
### Block disposable email TLDs
Only enforced when `env: "prod"`.
+59 -141
View File
@@ -1,75 +1,66 @@
import type { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runWithContext } from '../src/backend/core/context.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import {
handleAppTelemetryUserCount,
handleAppTelemetryUsers,
} from './appTelemetry.ts';
interface CapturedResponse {
body: unknown;
}
const makeReq = (query: Record<string, unknown> = {}): Request =>
({ query }) as unknown as Request;
const makeRes = () => {
const captured: CapturedResponse = { body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = async (
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
fn: () => Promise<void>,
) => runWithContext({ actor }, fn);
// Importing the module registers the `appTelemetry` driver into the shared
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
import { AppTelemetryDriver } from './appTelemetry.ts';
let server: PuterServer;
let driver: AppTelemetryDriver;
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = <T>(
actor: { user: { uuid: string; id?: number } } | undefined,
fn: () => Promise<T>,
) => runWithContext({ actor }, fn);
beforeAll(async () => {
server = await setupTestServer();
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
.appTelemetry;
// Guard: the driver must have been wired onto the server. If this is
// undefined the extension didn't register, and every test below would
// fail with a confusing "cannot read property of undefined".
expect(driver).toBeInstanceOf(AppTelemetryDriver);
});
afterAll(async () => {
await server.shutdown();
});
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
const seedOwnedApp = async (prefix: string) => {
const owner = await server.stores.user.create({
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `${prefix}_${slug}`,
title: `${prefix} ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
return { owner, app: app! };
};
describe('appTelemetry driver — get_users', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(makeReq({}), res),
).rejects.toMatchObject({
await expect(driver.get_users({})).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('app_uuid'),
});
});
it('throws HttpError(400) for a non-numeric limit', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
res,
),
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('limit'),
@@ -77,22 +68,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
res,
),
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws HttpError(404) when the app cannot be found', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-does-not-exist' }),
res,
),
driver.get_users({ app_uuid: 'app-does-not-exist' }),
).rejects.toMatchObject({
statusCode: 404,
message: 'App not found',
@@ -100,25 +83,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(403) when the caller does not own the app', async () => {
// Seed an owner user + an app belonging to them, then call the
// handler as a *different* user. The real permission service should
// reject since the caller has no `apps-of-user:<owner>:write` perm.
const owner = await server.stores.user.create({
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app_${slug}`,
title: `App ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
// Seed an owner + their app, then call as a *different* user. The
// real permission service should reject: the caller has no
// `apps-of-user:<owner>:write`.
const { app } = await seedOwnedApp('owner');
const intruder = await server.stores.user.create({
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
@@ -126,15 +94,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
email: null,
});
const { res } = makeRes();
await callWithActor(
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
async () => {
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
),
driver.get_users({ app_uuid: app.uid }),
).rejects.toMatchObject({
statusCode: 403,
message: 'Permission denied',
@@ -144,78 +108,32 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('returns an empty list for an owned app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app2_${slug}`,
title: `App2 ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { owner, app } = await seedOwnedApp('owner2');
const { res, captured } = makeRes();
await callWithActor(
const result = await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
async () => {
await handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
);
},
() => driver.get_users({ app_uuid: app.uid }),
);
expect(captured.body).toEqual([]);
expect(result).toEqual([]);
});
});
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
describe('appTelemetry driver — user_count', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(makeReq({}), res),
).rejects.toMatchObject({ statusCode: 400 });
await expect(driver.user_count({})).rejects.toMatchObject({
statusCode: 400,
});
});
it('throws HttpError(404) for an unknown app_uuid', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(
makeReq({ app_uuid: 'app-not-here' }),
res,
),
driver.user_count({ app_uuid: 'app-not-here' }),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns { count: 0 } for an app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `appcount_${slug}`,
title: `AppCount ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { res, captured } = makeRes();
await handleAppTelemetryUserCount(
makeReq({ app_uuid: app!.uid }),
res,
);
expect(captured.body).toEqual({ count: 0 });
it('returns 0 for an app with no authenticated users', async () => {
const { app } = await seedOwnedApp('appcount');
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
});
});
+88 -80
View File
@@ -1,11 +1,16 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import type { Actor } from '@heyputer/backend/src/core/actor';
import { HttpError } from '@heyputer/backend/src/core/http';
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
import { extension } from '@heyputer/backend/src/extensions';
const clients = extension.import('client');
const stores = extension.import('store');
const services = extension.import('service');
// App-telemetry lets an app owner enumerate the users who have
// authenticated into their app. v1 shipped this as a driver on the
// `app-telemetry` interface (methods `get_users` / `user_count`) and
// puter-js's `puter.apps(...).getUsers()` still calls it that way
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
// This is the v2 port of that driver — same interface/method/return shapes
// so existing puter-js callers work unchanged.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@ -41,91 +46,94 @@ const parseIntParam = (
return parsed;
};
export const handleAppTelemetryUsers = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
/**
* Driver exposing the `app-telemetry` interface.
*
* The `/drivers/call` permission gate checks
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
* via the blanket `service` grant (hardcoded-permissions.js +
* `default_implicit_user_app_permissions`). The real authorization — "is the
* caller the app owner?" — is enforced inside `get_users` below, exactly as
* v1 did.
*/
export class AppTelemetryDriver extends PuterDriver {
readonly driverInterface = 'app-telemetry';
readonly driverName = 'app-telemetry';
readonly isDefault = true;
const safeLimit = parseIntParam(req.query.limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(req.query.offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
/** Users who have authenticated into the given app (owner-only). */
async get_users({
app_uuid,
limit,
offset,
}: {
app_uuid?: string;
limit?: unknown;
offset?: unknown;
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const safeLimit = parseIntParam(limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly. v1 got
// this for free because its entity-storage layer eager-joined the
// owner row; v2's AppStore.getByUid returns the raw row with only
// `owner_user_id` populated.
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = (await stores.user.getById(ownerId)) as {
uuid?: string;
} | null;
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const actor = Context.get('actor');
const ownsApp = await services.permission
.check(actor!, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly — the raw
// app row only carries `owner_user_id`. (v1 got the owner for free
// because its entity-storage layer eager-joined the owner row.)
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = await this.stores.user.getById(ownerId);
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const users = await clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
);
const actor = Context.get('actor');
if (!actor) throw new HttpError(401, 'Authentication required');
const ownsApp = await this.services.permission
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
res.json(
(users as Array<{ username: string; uuid: string }>).map((e) => ({
user: e.username,
user_uuid: e.uuid,
})),
);
};
const users = (await this.clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[(app as { id: number }).id, safeLimit, safeOffset],
)) as Array<{ username: string; uuid: string }>;
export const handleAppTelemetryUserCount = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
return users.map((e) => ({ user: e.username, user_uuid: e.uuid }));
}
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
/** Count of users who have authenticated into the given app. */
async user_count({
app_uuid,
}: { app_uuid?: string } = {}): Promise<number> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const [row] = (await clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as Record<string, unknown>).id],
)) as Array<{ n: number }>;
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
res.json({ count: row?.n ?? 0 });
};
const [row] = (await this.clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as { id: number }).id],
)) as Array<{ n: number }>;
extension.get(
'/app-telemetry/users',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUsers,
);
return row?.n ?? 0;
}
}
extension.get(
'/app-telemetry/user-count',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUserCount,
);
extension.registerDriver('appTelemetry', AppTelemetryDriver);
+32
View File
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
expect(list[0].uid).toBe(app!.uid);
expect(list[0].name).toBe(app!.name);
expect(list[0].title).toBe(app!.title);
// index_url is required so the dashboard can derive a hostname title
// for anonymous (app-…) apps.
expect(list[0].index_url).toBe(app!.index_url);
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
true,
);
// An owned app is not external, and the raw owner id must not leak.
expect(list[0].external).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('flags apps with no owner_user_id as external', async () => {
const user = await seedUser();
const slug = Math.random().toString(36).slice(2, 8);
// createFromOrigin bootstraps an app with owner_user_id = null.
const app = await server.stores.app.createFromOrigin(
`app-${slug}`,
`https://external-${slug}.example.com`,
);
await grantInstalled(app!.id as number, user.id as number);
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleInstalledApps(makeReq({}), res),
);
const list = captured.body as Array<Record<string, unknown>>;
expect(list).toHaveLength(1);
expect(list[0].external).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
+14 -4
View File
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
apps.title,
apps.description,
apps.icon,
apps.index_url,
apps.owner_user_id,
MIN(perm.dt) AS installed_at
FROM apps
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
@@ -62,10 +64,18 @@ export const handleInstalledApps = async (
const apiBaseUrl = extension.config.api_base_url as string | undefined;
res.json(
installedApps.map((app) => ({
...app,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
})),
installedApps.map((app) => {
// An app with no owner_user_id (null/empty) isn't owned by a Puter
// user — it's an "external" app. Derive a flag and don't leak the
// raw owner id to the client.
const { owner_user_id, ...rest } = app;
const external = owner_user_id == null || owner_user_id === '';
return {
...rest,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
external,
};
}),
);
};
+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();
+2
View File
@@ -79,6 +79,7 @@ export const handleWhoami = async (
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
oidc_only: oidcOnly,
taskbar_items: isUser
? await getTaskbarItems(
@@ -160,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) {
+4791 -5043
View File
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -46,7 +46,7 @@
"scripts": {
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run setupExtensions && npm run build:ts",
"dev": "npm start",
@@ -84,9 +84,13 @@
"node": ">=24.0.0"
},
"allowScripts": {
"[email protected]": true,
"[email protected]": true,
"@google/genai": false,
"[email protected].1": true
"[email protected].1": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
}
}
@@ -88,6 +88,11 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[52, ['0057_add_user_reputation.sql']],
[53, ['0058_add_phone_verification.sql']],
[54, ['0059_add_card_verification.sql']],
[55, ['0060_add_card_fingerprint.sql']],
[56, ['0061_add_suspended_at.sql']],
[57, ['0062_blocked-app-origins.sql']],
[58, ['0063_add_suspended_reason.sql']],
[59, ['0064_abuse-moderation-events.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,46 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations); the index add is guarded against
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_suspended_at_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_suspended_at'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_suspended_at_index();
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
@@ -0,0 +1,36 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
-- An app whose `index_url` host (or a request origin) matches an entry is
-- denied access to Puter resources: it cannot obtain an app token and
-- already-issued app tokens are rejected on each request. `include_subdomains
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
-- AppOriginBlocklistService.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
`id` INT NOT NULL AUTO_INCREMENT,
`domain` VARCHAR(255) NOT NULL,
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
`reason` TEXT DEFAULT NULL,
`created_by` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,26 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations).
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
@@ -0,0 +1,38 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
-- gives the denominator. Preserves history the `user` suspension columns lose
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
`id` INT NOT NULL AUTO_INCREMENT,
`action` VARCHAR(32) NOT NULL,
`target_user_id` BIGINT DEFAULT NULL,
`target_username` VARCHAR(255) DEFAULT NULL,
`admin_username` VARCHAR(255) DEFAULT NULL,
`reason` TEXT DEFAULT NULL,
`source` VARCHAR(64) DEFAULT NULL,
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
KEY `idx_abuse_moderation_events_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,25 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
@@ -0,0 +1,36 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
-- `index_url` host (or a request origin) matches an entry is denied access to
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS blocked_app_origins (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
include_subdomains SMALLINT NOT NULL DEFAULT 0,
reason TEXT DEFAULT NULL,
created_by VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
ON blocked_app_origins (domain);
@@ -0,0 +1,24 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
@@ -0,0 +1,40 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
-- signals; `suspend` gives the denominator. Preserves history the `user`
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
id BIGSERIAL PRIMARY KEY,
action VARCHAR(32) NOT NULL,
target_user_id BIGINT DEFAULT NULL,
target_username VARCHAR(255) DEFAULT NULL,
admin_username VARCHAR(255) DEFAULT NULL,
reason TEXT DEFAULT NULL,
source VARCHAR(64) DEFAULT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON abuse_moderation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON abuse_moderation_events (action);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- When the account was suspended, as unix seconds; NULL while not suspended.
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
@@ -0,0 +1,34 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
-- a request origin) matches an entry is denied access to Puter resources:
-- it cannot obtain an app token and already-issued app tokens are rejected
-- on each request. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"domain" TEXT NOT NULL,
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
"reason" TEXT DEFAULT NULL,
"created_by" TEXT DEFAULT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
ON `blocked_app_origins` (`domain`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Why an account was suspended, NULL while not suspended. Companion to the
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
-- application layer to a fixed set of reasons (see extensions/admin
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
-- needs no migration.
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
@@ -0,0 +1,43 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions, used to measure the abuse
-- system's false-positive rate: `unsuspend` (admin unblock) and
-- `admin_create_user` (an account minted by an admin, typically to recover a
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
-- columns on `user` — which are cleared on unsuspend and so keep no history —
-- this table preserves every event. `created_at` is unix seconds (matching
-- `user.suspended_at`), stamped by the recorder in
-- extensions/admin/moderation_events.js. Written best-effort at the admin
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
"target_user_id" INTEGER DEFAULT NULL,
"target_username" TEXT DEFAULT NULL,
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
"reason" TEXT DEFAULT NULL,
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
"created_at" INTEGER NOT NULL -- unix seconds
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON `abuse_moderation_events` (`created_at`);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON `abuse_moderation_events` (`action`);
+16 -2
View File
@@ -120,6 +120,21 @@ export type EventMap = {
actor: unknown;
result: { allowed: boolean; reason?: string; error?: string };
};
// Emitted after an app token is granted (get-user-app-token). The app-side
// analogue of `site.htmlServed` — the external-app abuse scanner
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
// external `index_url`s that Puter never serves itself.
'puter.app.authenticated': {
app_uid?: string;
user_id?: number | null;
app?: {
id?: number;
uid?: string;
index_url?: string | null;
owner_user_id?: number | null;
name?: string | null;
};
};
// ---- Auth / signup ----
'puter.signup.validate': {
@@ -137,9 +152,8 @@ export type EventMap = {
* the request for log / support correlation.
*/
trail_id?: string;
/** Device signals forwarded verbatim from the signup request body. */
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: string | null;
dfp_telemetry_id?: string | null;
/** Set by the abuse harness — require SMS phone verification post-signup. */
requires_phone_verification?: boolean;
/** Set by the abuse harness — require card verification post-signup. */
@@ -79,12 +79,28 @@ describe('PreludeClient', () => {
expect(init.headers.Authorization).toBe('Bearer sk_test');
expect(JSON.parse(init.body)).toEqual({
target: { type: 'phone_number', value: '+14155550123' },
// Defaults to RCS (cheaper); Prelude falls back to SMS.
options: { code_size: 6, preferred_channel: 'rcs' },
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
// hardcoded to en-US so the message text is always English.
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
signals: { ip: '203.0.113.7' },
});
});
it('returns the delivery channel sequence Prelude reports', async () => {
fetchMock.mockResolvedValue(
okJson({
id: 'vrf_1',
status: 'success',
channels: ['whatsapp', 'sms'],
}),
);
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123');
expect(res.channels).toEqual(['whatsapp', 'sms']);
});
it('forwards device_id and user_agent signals when supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
@@ -114,6 +130,32 @@ describe('PreludeClient', () => {
);
});
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
expect(body.signals).toEqual({ ip: '203.0.113.7' });
expect(body.signals).not.toHaveProperty('dispatch_id');
});
it('omits dispatch_id when not supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
'dispatch_id',
);
});
it('includes a configured template_id + sender_id + preferred channel', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = new PreludeClient({
@@ -131,6 +173,7 @@ describe('PreludeClient', () => {
expect(JSON.parse(init.body).options).toEqual({
code_size: 6,
preferred_channel: 'sms', // config override beats the rcs default
locale: 'en-US',
template_id: 'tmpl_puter',
sender_id: 'Puter',
});
+16 -12
View File
@@ -42,6 +42,12 @@ export type PreludeChannel =
| 'viber'
| 'zalo'
| 'telegram';
/**
* Channels Prelude reports in a verification's `channels` array — the ordered
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
* and 'voice' can appear as delivery methods but can't be preferred.
*/
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
/**
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
* this — or that have no SMS channel — are not offered phone verification. The
@@ -117,25 +123,19 @@ export class PreludeClient extends PuterClient {
return price.sms <= this.maxSmsCostEur;
}
/**
* Create (or retry) a verification: Prelude sends an OTP to `target`.
* @param target E.164 phone number, e.g. "+14155550123".
* @param signals Optional anti-fraud signals. The more we forward, the
* better Prelude's fraud model scores the attempt (per their docs IP +
* device + platform are the highest-value). We pass what the backend
* already has: the request `ip`, the client `device_id` (ThumbmarkJS
* hash), and the `user_agent` (Prelude infers platform/model/OS from it).
* Richer client-collected signals require Prelude's frontend SDK, which
* produces a `dispatch_id` the backend would forward here.
*/
async createVerification(
target: string,
signals: {
ip?: string;
device_id?: string;
user_agent?: string;
dispatch_id?: string;
} = {},
): Promise<{ id?: string; status: PreludeCreateStatus }> {
): Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}> {
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
// code_size Prelude uses the dashboard default (4). preferred_channel
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
@@ -144,6 +144,7 @@ export class PreludeClient extends PuterClient {
preferred_channel:
this.config.prelude?.preferredChannel ??
DEFAULT_PREFERRED_CHANNEL,
locale: 'en-US',
};
// Branding lives in the Prelude dashboard (the message text is a
// template); these just select a Puter-branded template / sender when
@@ -163,9 +164,12 @@ export class PreludeClient extends PuterClient {
if (signals.device_id) sig.device_id = signals.device_id;
if (signals.user_agent) sig.user_agent = signals.user_agent;
if (Object.keys(sig).length > 0) body.signals = sig;
// dispatch_id is a top-level field, not a member of `signals`.
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
return this.#post('/verification', body) as Promise<{
id?: string;
status: PreludeCreateStatus;
channels?: PreludeDeliveryChannel[];
}>;
}
@@ -586,6 +586,24 @@ describe('AuthController.handleSignup', () => {
);
});
it('rejects brand-new temp signups when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
await expect(
controller.handleSignup(makeReq({ is_temp: true }), makeRes()),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
it('emits puter.signup.success on successful signup', async () => {
const baseline = heardSignupSuccess.length;
const username = `s_${uniq()}`;
@@ -607,9 +625,93 @@ describe('AuthController.handleSignup', () => {
),
).toBe(true);
});
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
const targetEmail = `disabled_claim_${uniq()}@test.local`;
const placeholder = await server.stores.user.create({
username: `placeholder_${uniq()}`,
uuid: uuidv4(),
password: null,
email: targetEmail,
clean_email: targetEmail,
email_confirmed: 0,
} as never);
const res = makeRes();
await controller.handleSignup(
makeReq({
username: `claim_${uniq()}`,
email: targetEmail,
password: 'correct-horse-battery',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const claimed = await server.stores.user.getById(placeholder.id, {
force: true,
});
expect(claimed!.username).not.toBe(placeholder.username);
} finally {
authConfig.disable_user_signup = prev;
}
});
it('does not reveal existing usernames or emails when registration is disabled', async () => {
const username = `taken_${uniq()}`;
const email = `${username}@test.local`;
await controller.handleSignup(
makeReq({ username, email, password: 'correct-horse-battery' }),
makeRes(),
);
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
};
const prev = authConfig.disable_user_signup;
authConfig.disable_user_signup = true;
try {
// Taken username → the generic 403, not the duplicate error.
await expect(
controller.handleSignup(
makeReq({
username,
email: `fresh_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
// Taken (non-claimable) email → same generic 403.
await expect(
controller.handleSignup(
makeReq({
username: `fresh_${uniq()}`,
email,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'signup_disabled',
});
} finally {
authConfig.disable_user_signup = prev;
}
});
});
// -- Signup device signals (fingerprint + dfp_telemetry_id) --
// -- Signup device signal (fingerprint) --
describe('AuthController.handleSignup device signals', () => {
const uniq = () => Math.random().toString(36).slice(2, 10);
@@ -631,11 +733,10 @@ describe('AuthController.handleSignup device signals', () => {
(evt) => (evt as { username?: string }).username === username,
);
it('forwards fingerprint and dfp_telemetry_id verbatim to validate and success events', async () => {
it('forwards fingerprint verbatim to validate and success events', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const fingerprint = 'Fp_abc.123-XYZ';
const dfpTelemetryId = 'tel_id-456';
const seen = await captureValidateEvents(async () => {
const res = makeRes();
@@ -645,7 +746,6 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint,
dfp_telemetry_id: dfpTelemetryId,
}),
res,
);
@@ -654,7 +754,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBe(fingerprint);
expect(seen[0].dfp_telemetry_id).toBe(dfpTelemetryId);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
@@ -662,7 +761,7 @@ describe('AuthController.handleSignup device signals', () => {
expect(successes[0].is_temp).toBe(false);
});
it('accepts boundary-length values (128-char fingerprint, 64-char dfp_telemetry_id)', async () => {
it('accepts a boundary-length 128-char fingerprint', async () => {
const username = `fp_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
@@ -671,14 +770,13 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(128),
dfp_telemetry_id: 'd'.repeat(64),
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('defaults both fields to null on the validate event when absent', async () => {
it('defaults the fingerprint to null on the validate event when absent', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
@@ -699,7 +797,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
@@ -707,7 +804,7 @@ describe('AuthController.handleSignup device signals', () => {
expect(successes[0].is_temp).toBe(false);
});
it('treats empty-string fingerprint and dfp_telemetry_id as absent', async () => {
it('treats an empty-string fingerprint as absent', async () => {
const username = `fp_${uniq()}`;
const seen = await captureValidateEvents(async () => {
@@ -718,7 +815,6 @@ describe('AuthController.handleSignup device signals', () => {
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: '',
dfp_telemetry_id: '',
}),
res,
);
@@ -728,7 +824,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
});
it('rejects a non-string fingerprint with 400 and fires no success event', async () => {
@@ -770,37 +865,6 @@ describe('AuthController.handleSignup device signals', () => {
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a dfp_telemetry_id longer than 64 characters with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: 'd'.repeat(65),
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a non-string dfp_telemetry_id with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `fp_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: { nested: true },
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
@@ -1902,6 +1966,48 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
);
});
it('attaches a support error_id to a Prelude block', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({ status: 'blocked' })),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { error_id: expect.any(String) },
});
},
);
});
it('attaches a support error_id when the Prelude request throws', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => {
throw new Error('network down');
}),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 502,
fields: { error_id: expect.any(String) },
});
},
);
});
it('forwards ip, device fingerprint, and user-agent to Prelude as signals', async () => {
const { actor } = await makeUserAndActor();
const createVerification = vi.fn(async () => ({ status: 'success' }));
@@ -1941,6 +2047,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
user_agent: undefined,
});
});
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'success',
channels: ['whatsapp', 'sms'],
})),
}),
async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toMatchObject({ channel: 'whatsapp' });
},
);
});
it('omits `channel` when Prelude reports no delivery sequence', async () => {
const { actor } = await makeUserAndActor();
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).not.toHaveProperty('channel');
});
});
});
describe('AuthController phone verification — staging & reuse', () => {
@@ -2240,6 +2378,47 @@ describe('AuthController.handleConfirmPhone', () => {
);
});
it('awaits the user.phone-verified listeners (emitAndWait, not fire-and-forget)', async () => {
// The carrier-based card-verification waiver (abuse extension) listens
// on user.phone-verified and must clear the card gate BEFORE confirm
// responds — so the event has to be awaited, not fire-and-forget.
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
const emit = vi.fn();
const emitAndWait = vi.fn(async () => {});
const ctrl = controller as { clients: { event: unknown } };
const realEvent = ctrl.clients.event;
ctrl.clients.event = { emit, emitAndWait };
try {
await withPrelude(
stubPrelude({
checkVerification: vi.fn(async () => ({
status: 'success',
})),
}),
async () => {
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
makeRes(),
);
},
);
} finally {
ctrl.clients.event = realEvent;
}
expect(emitAndWait).toHaveBeenCalledWith(
'user.phone-verified',
expect.objectContaining({ phone: '+14155550123' }),
expect.anything(),
);
const fireAndForget = emit.mock.calls.filter(
(c) => c[0] === 'user.phone-verified',
);
expect(fireAndForget).toHaveLength(0);
});
it('throws 502 when the upstream check fails', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
@@ -2489,6 +2668,312 @@ describe('AuthController.handleCardVerificationConfirm', () => {
});
});
describe('AuthController SMS → card fallback', () => {
const stubPrelude = (over: Record<string, unknown> = {}) => ({
isConfigured: () => true,
isCountrySupported: () => true,
defaultCountry: 'US',
createVerification: vi.fn(async () => ({ status: 'success' })),
...over,
});
const withPrelude = async (
prelude: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const ctrl = controller as { clients: { prelude: unknown } };
const real = ctrl.clients.prelude;
ctrl.clients.prelude = prelude;
try {
await fn();
} finally {
ctrl.clients.prelude = real;
}
};
const withFallbackConfig = async (
value: unknown,
fn: () => Promise<void>,
): Promise<void> => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.phone_verification_card_fallback;
cfg.phone_verification_card_fallback = value;
try {
await fn();
} finally {
cfg.phone_verification_card_fallback = prev;
}
};
// Drive the attempt counter directly so the threshold is deterministic
// (the handler keys it the same way: `phone-verify-attempts:<id>`).
const seedAttempts = (userId: number, attempts: number) =>
server.stores.kv.incr({
key: `phone-verify-attempts:${userId}`,
pathAndAmountMap: { attempts },
});
// Stamp the eligibility flag the card endpoints check, the same way a
// threshold-crossing send does (`card-fallback-open:<id>`).
const openFallback = (userId: number) =>
server.stores.kv.set({
key: `card-fallback-open:${userId}`,
value: true,
});
it('offers the fallback on send once the attempt threshold is reached', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// No after_attempts → exercises the default threshold of 2.
await withFallbackConfig({ enabled: true }, async () => {
await withPrelude(stubPrelude(), async () => {
const first = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
first,
);
// First attempt is below the threshold — no offer yet.
expect(first.body).toEqual({});
const second = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
second,
);
expect(second.body).toEqual({
card_fallback_available: true,
});
});
});
});
it('never offers the fallback on send when disabled', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: false, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({});
});
},
);
});
it('flags the fallback on a Prelude block once eligible', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(
stubPrelude({
createVerification: vi.fn(async () => ({
status: 'blocked',
})),
}),
async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
fields: { card_fallback_available: true },
});
},
);
},
);
});
it('lets card setup proceed past the phone gate once eligible', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('still 409s card setup when no send has opened the fallback', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
// Counter above the threshold but no flag: eligibility is the flag a
// threshold-crossing send stamps, never the raw counter.
await seedAttempts(user.id, 5);
await withFallbackConfig(
{ enabled: true, after_attempts: 3 },
async () => {
await expect(
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
},
);
});
it('send crossing the threshold opens card setup end-to-end', async () => {
const { actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
});
await withFallbackConfig(
{ enabled: true, after_attempts: 1 },
async () => {
await withPrelude(stubPrelude(), async () => {
const sendRes = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
sendRes,
);
expect(sendRes.body).toEqual({
card_fallback_available: true,
});
});
const res = makeRes();
await withCardSetupOverride(
(data) => {
data.enabled = true;
data.client_secret = 'seti_secret';
data.publishable_key = 'pk_test';
},
() =>
controller.handleCardVerificationSetup(
makeReq({}, { actor }),
res,
),
);
expect(res.body).toEqual({
client_secret: 'seti_secret',
publishable_key: 'pk_test',
});
},
);
});
it('clamps after_attempts to the send route rate limit', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
});
// 9 prior attempts + this send = 10, the route limit. A threshold of
// 50 could never be crossed, so it clamps down and the offer opens.
await seedAttempts(user.id, 9);
await withFallbackConfig(
{ enabled: true, after_attempts: 50 },
async () => {
await withPrelude(stubPrelude(), async () => {
const res = makeRes();
await controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
res,
);
expect(res.body).toEqual({
card_fallback_available: true,
});
});
},
);
});
it('clears BOTH gates when the fallback card verifies', async () => {
const { user, actor } = await makeUserAndActor({
requires_card_verification: 1,
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({
card_verified: true,
phone_verified: true,
});
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_card_verification).toBe(false);
expect(after!.requires_phone_verification).toBe(false);
});
it('clears the phone gate via card even when card was not required', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
phone: '+14155550123',
});
await openFallback(user.id);
await withFallbackConfig(
{ enabled: true },
async () => {
const res = makeRes();
await withCardConfirmOverride(
(data) => {
data.enabled = true;
data.verified = true;
},
() =>
controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
),
);
expect(res.body).toMatchObject({ phone_verified: true });
},
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.requires_phone_verification).toBe(false);
});
});
describe('AuthController.handleConfirmEmail', () => {
it('throws 400 when code is missing', async () => {
const { actor } = await makeUserAndActor();
+357 -53
View File
@@ -23,6 +23,7 @@ import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import type { HttpErrorOptions } from '../../core/http/HttpError.js';
import { HttpError } from '../../core/http/HttpError.js';
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
@@ -47,8 +48,8 @@ import {
} from '../../services/auth/OTPUtil.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { parsePhone } from '../../util/phone.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
generateDefaultFsentries,
@@ -59,7 +60,18 @@ import { PuterController } from '../types.js';
const USERNAME_REGEX = /^\w{1,}$/;
const USERNAME_MAX_LENGTH = 45;
const FINGERPRINT_MAX_LENGTH = 128;
const DFP_TELEMETRY_ID_MAX_LENGTH = 64;
const DISPATCH_ID_MAX_LENGTH = 128;
// Default SMS send attempts before the card fallback opens.
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
// /send-confirm-phone route rate limit. Also caps the fallback's
// `after_attempts`: requests past the route limit are rejected in middleware
// and never reach the attempt counter, so a higher threshold could never be
// crossed.
const SEND_PHONE_RATE_LIMIT = 10;
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
// Once the threshold is crossed the fallback stays open this long, so the
// user can finish the card flow without racing the attempt counter's expiry.
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -378,11 +390,10 @@ export class AuthController extends PuterController {
return;
}
// Optional device signals (browser fingerprint hash, DFP telemetry
// id). Core only enforces shape and forwards the values verbatim —
// signup-abuse policy built on them lives in extensions. Checked
// before the reauth short-circuit so a malformed value is rejected
// on every /signup path.
// Optional device signal (browser fingerprint hash). Core only enforces
// shape and forwards the value verbatim — signup-abuse policy built on it
// lives in extensions. Checked before the reauth short-circuit so a
// malformed value is rejected on every /signup path.
if (body.fingerprint !== undefined && body.fingerprint !== null) {
if (typeof body.fingerprint !== 'string')
throw new HttpError(400, 'fingerprint must be a string.', {
@@ -395,25 +406,9 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
if (
body.dfp_telemetry_id !== undefined &&
body.dfp_telemetry_id !== null
) {
if (typeof body.dfp_telemetry_id !== 'string')
throw new HttpError(400, 'dfp_telemetry_id must be a string.', {
legacyCode: 'bad_request',
});
if (body.dfp_telemetry_id.length > DFP_TELEMETRY_ID_MAX_LENGTH)
throw new HttpError(
400,
`dfp_telemetry_id cannot be longer than ${DFP_TELEMETRY_ID_MAX_LENGTH} characters.`,
{ legacyCode: 'bad_request' },
);
}
// Empty strings are treated as absent — a signal that wasn't
// collected, not a malformed request.
const fingerprint: string | null = body.fingerprint || null;
const dfp_telemetry_id: string | null = body.dfp_telemetry_id || null;
// Temp-user reauth short-circuit: when an existing temp user is
// forced through the reauth flow, the GUI re-submits /signup with
@@ -527,6 +522,31 @@ export class AuthController extends PuterController {
}
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
// allowed, so permanent signups look the email up first.
if (this.config.disable_user_signup) {
let claimable = false;
if (!is_temp) {
const existing =
(await this.stores.user.getByEmail(body.email)) ??
(await this.stores.user.getByCleanEmail(
cleanEmail(body.email),
));
claimable = Boolean(
existing &&
!existing.email_confirmed &&
existing.password === null,
);
}
if (!claimable) {
throw new HttpError(403, 'User registration is disabled.', {
legacyCode: 'signup_disabled',
});
}
}
// Duplicate username check
if (await this.stores.user.getByUsername(body.username)) {
throw new HttpError(
@@ -605,7 +625,6 @@ export class AuthController extends PuterController {
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
fingerprint,
dfp_telemetry_id,
// Populated by the abuse extension's v2 harness; persisted to the
// user row below so the signup-time reputation is referable later.
reputation: null as number | null,
@@ -746,7 +765,6 @@ export class AuthController extends PuterController {
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
fingerprint,
dfp_telemetry_id,
},
signup_ip: clientIp,
signup_ip_forwarded: proxyIpChain,
@@ -1037,14 +1055,154 @@ export class AuthController extends PuterController {
// -- Phone verification (SMS via Prelude) ------------------------
/**
* Build the error thrown when a verification SMS can't be sent (a delivery
* failure, or a refused/blocked send). Mints a short `error_id`, writes a
* single greppable line tying that id to the real reason (so support can
* look it up in CloudWatch with the id the user quotes), and returns the
* `HttpError` with the id attached as `error_id` for the GUI to surface.
* The phone number is deliberately omitted from the log line (PII); the
* user + country are enough to correlate.
*/
private smsSendError(
statusCode: number,
clientMessage: string,
reason: string,
ctx: {
userId?: number;
userUid?: string;
country?: string;
detail?: unknown;
},
options: HttpErrorOptions = {},
): HttpError {
const errorId = uuidv4();
const detail =
ctx.detail instanceof Error ? ctx.detail.message : ctx.detail;
console.warn(
`[send-confirm-phone] send_failed error_id=${errorId} ` +
`reason=${reason} status=${statusCode} ` +
`user_id=${ctx.userId ?? ''} user_uid=${ctx.userUid ?? ''} ` +
`country=${ctx.country ?? ''}` +
(detail ? ` detail=${JSON.stringify(String(detail))}` : ''),
);
return new HttpError(statusCode, clientMessage, {
...options,
fields: { ...options.fields, error_id: errorId },
});
}
// -- SMS-to-card fallback -----------------------------------------
//
// Once a user has made enough SMS send attempts in the rate-limit window
// without getting through, they can verify a card instead to clear the
// phone gate. Off unless config enables it.
//
// Two KV keys: a short-lived counter tied to the send rate-limit window
// triggers the fallback, and a longer-lived "open" flag holds eligibility
// once the threshold is crossed. The card endpoints check only the flag —
// deriving eligibility from the raw counter would let it expire while the
// user is mid-way through the card flow. Every KV failure fails closed
// (fallback unavailable), never open.
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
const cfg = this.config.phone_verification_card_fallback;
const afterAttempts = Math.min(
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
? cfg.after_attempts
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
SEND_PHONE_RATE_LIMIT,
);
return { enabled: Boolean(cfg?.enabled), afterAttempts };
}
private phoneAttemptsKey(userId: number): string {
return `phone-verify-attempts:${userId}`;
}
private cardFallbackFlagKey(userId: number): string {
return `card-fallback-open:${userId}`;
}
// TTL ties the counter to the send rate-limit window, so it resets with it.
private async bumpPhoneAttempts(userId: number): Promise<number> {
try {
const { res } = await this.stores.kv.incr({
key: this.phoneAttemptsKey(userId),
pathAndAmountMap: { attempts: 1 },
expireAt:
Math.floor(Date.now() / 1000) +
SEND_PHONE_RATE_WINDOW_MS / 1000,
});
const count = (res as { attempts?: number } | null)?.attempts;
return typeof count === 'number' ? count : 0;
} catch (e) {
console.warn('[send-confirm-phone] attempt-count bump failed:', e);
return 0;
}
}
/**
* Count a send attempt and, once the threshold is crossed, stamp the
* eligibility flag the card endpoints check. Returns whether the fallback
* is open so send responses (success or 429) can advertise it.
*/
private async recordPhoneAttemptForFallback(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const attempts = await this.bumpPhoneAttempts(user.id);
const { enabled, afterAttempts } = this.cardFallbackConfig();
const open =
enabled &&
Boolean(user.requires_phone_verification) &&
attempts >= afterAttempts;
if (open) {
try {
// Plain set, so each eligible attempt refreshes the window.
await this.stores.kv.set({
key: this.cardFallbackFlagKey(user.id),
value: true,
expireAt:
Math.floor(Date.now() / 1000) +
CARD_FALLBACK_OPEN_TTL_SECONDS,
});
} catch (e) {
console.warn(
'[send-confirm-phone] fallback flag stamp failed:',
e,
);
return false;
}
}
return open;
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
}): Promise<boolean> {
const { enabled } = this.cardFallbackConfig();
if (!enabled || !user.requires_phone_verification) return false;
try {
const { res } = await this.stores.kv.get({
key: this.cardFallbackFlagKey(user.id),
});
return res === true;
} catch (e) {
console.warn('[card-verification] fallback flag read failed:', e);
return false;
}
}
@Post('/send-confirm-phone', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
rateLimit: {
scope: 'send-confirm-phone',
limit: 10,
window: 60 * 60_000,
limit: SEND_PHONE_RATE_LIMIT,
window: SEND_PHONE_RATE_WINDOW_MS,
key: 'user',
},
})
@@ -1061,9 +1219,13 @@ export class AuthController extends PuterController {
legacyCode: 'account_suspended',
});
if (!this.clients.prelude?.isConfigured())
throw new HttpError(503, 'Phone verification is unavailable.', {
legacyCode: 'service_unavailable' as never,
});
throw this.smsSendError(
503,
'Phone verification is unavailable.',
'prelude_not_configured',
{ userId: user.id, userUid: user.uuid },
{ legacyCode: 'service_unavailable' as never },
);
// Parse to E.164 (Prelude's required form + the stored form) and the
// country, so we can apply the per-country cost cap.
@@ -1076,16 +1238,42 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
// Optional Prelude dispatch id (browser signals gathered by the JS
// Signals SDK on the number-entry page). Shape-checked and forwarded
// verbatim to Prelude; an empty / oversized / non-string value is just
// dropped so a bad client signal never blocks a real verification.
const rawDispatchId = req.body?.dispatch_id;
const dispatchId =
typeof rawDispatchId === 'string' &&
rawDispatchId.length > 0 &&
rawDispatchId.length <= DISPATCH_ID_MAX_LENGTH
? rawDispatchId
: undefined;
// Cost cap: skip countries with no SMS channel or rates above the cap
// (see PreludeClient / countries.ts). Avoids paying exorbitant per-SMS
// rates in low-revenue, high-fraud geographies.
if (!this.clients.prelude.isCountrySupported(parsed.country))
throw new HttpError(
throw this.smsSendError(
400,
'Phone verification is not available for this country.',
'country_not_supported',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{ legacyCode: 'phone_country_not_supported' as never },
);
// Counted before the abuse / Prelude checks so a blocked attempt still
// counts toward the fallback threshold.
const fallbackAvailable =
await this.recordPhoneAttemptForFallback(user);
const fallbackFields = fallbackAvailable
? { card_fallback_available: true }
: {};
// Abuse caps live ENTIRELY in a listening abuse extension, consulted
// via `puter.phone-verification.check`. The backend ships no thresholds
// or detection of its own (so none of it is readable in the open-source
@@ -1117,14 +1305,23 @@ export class AuthController extends PuterController {
// its meaning lives in the extension (which sets it) and the GUI (which
// displays it), so no abuse semantics leak into the OSS repo.
if (abuseCheck.allowed === false)
throw new HttpError(
throw this.smsSendError(
429,
'Phone verification is unavailable for this number right now.',
`not_allowed:${abuseCheck.reason ?? 'unspecified'}`,
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{
legacyCode: 'phone_verification_unavailable' as never,
fields: abuseCheck.reason
? { reason: abuseCheck.reason }
: {},
fields: {
...fallbackFields,
...(abuseCheck.reason
? { reason: abuseCheck.reason }
: {}),
},
},
);
@@ -1142,10 +1339,18 @@ export class AuthController extends PuterController {
expireAt: Math.floor(Date.now() / 1000) + 60 * 60,
});
} catch (e) {
console.warn('[send-confirm-phone] pending-store failed:', e);
throw new HttpError(503, 'Could not start phone verification.', {
legacyCode: 'service_unavailable' as never,
});
throw this.smsSendError(
503,
'Could not start phone verification.',
'pending_store_failed',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
detail: e,
},
{ legacyCode: 'service_unavailable' as never },
);
}
const ip = req.ip || req.socket?.remoteAddress || undefined;
@@ -1153,6 +1358,10 @@ export class AuthController extends PuterController {
typeof req.headers['user-agent'] === 'string'
? req.headers['user-agent']
: undefined;
// First entry of Prelude's delivery sequence — where the code actually
// went. Returned to the client so it can point the user at the right
// app (e.g. "check WhatsApp" instead of "check your texts").
let deliveryChannel: string | undefined;
try {
const result = await this.clients.prelude.createVerification(
parsed.e164,
@@ -1160,25 +1369,44 @@ export class AuthController extends PuterController {
ip,
device_id: req.deviceFingerprint ?? undefined,
user_agent: userAgent,
dispatch_id: dispatchId,
},
);
deliveryChannel = result.channels?.[0];
// Prelude rejected the attempt as abusive — surface as rate-limit.
if (
result.status === 'blocked' ||
result.status === 'shadow_blocked'
) {
throw new HttpError(
throw this.smsSendError(
429,
'Phone verification is temporarily unavailable for this number.',
{ legacyCode: 'too_many_requests' as never },
`prelude_${result.status}`,
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
},
{
legacyCode: 'too_many_requests' as never,
fields: fallbackFields,
},
);
}
} catch (e) {
if (e instanceof HttpError) throw e;
console.warn('[send-confirm-phone] createVerification failed:', e);
throw new HttpError(502, 'Could not send verification code.', {
legacyCode: 'upstream_error' as never,
});
throw this.smsSendError(
502,
'Could not send verification code.',
'prelude_request_failed',
{
userId: user.id,
userUid: user.uuid,
country: parsed.country,
detail: e,
},
{ legacyCode: 'upstream_error' as never },
);
}
// Tell the abuse extension a code was actually sent, so it can bump its
@@ -1199,7 +1427,10 @@ export class AuthController extends PuterController {
} catch {
// ignore — best-effort velocity signal
}
res.json({});
res.json({
...fallbackFields,
...(deliveryChannel ? { channel: deliveryChannel } : {}),
});
}
@Post('/confirm-phone', {
@@ -1281,8 +1512,14 @@ export class AuthController extends PuterController {
phone: pendingPhone,
});
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
// the abuse extension lands BEFORE we broadcast "refresh" and respond —
// otherwise the client re-fetches and still sees the card gate. Load-
// bearing now; emitAndWait swallows listener errors, so this stays
// best-effort and never blocks confirm on a listener.
try {
this.clients.event?.emit(
await this.clients.event?.emitAndWait(
'user.phone-verified' as never,
{
user_id: user.id,
@@ -1292,7 +1529,7 @@ export class AuthController extends PuterController {
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
// ignore — listeners are best-effort
}
// Notify other tabs/devices for this user so they refresh + drop the gate.
try {
@@ -1344,11 +1581,14 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (!user.requires_card_verification) {
// Phone normally comes first, but the fallback lets a phone-gated user
// in once they've exhausted SMS attempts.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1396,6 +1636,16 @@ export class AuthController extends PuterController {
// Kill switch: the extension reports the feature disabled — unstick
// any user still carrying the flag instead of dead-ending them.
if (setupEvent.enabled === false) {
// A fallback user is here BECAUSE SMS isn't working for them, and
// now the card path is off too — they stay phone-gated with no
// way through. Surface it; don't clear a gate with nothing
// verified.
if (fallbackEligible)
console.warn(
'[card-verification/setup] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1452,11 +1702,13 @@ export class AuthController extends PuterController {
throw new HttpError(404, 'User not found.', {
legacyCode: 'not_found',
});
if (!user.requires_card_verification) {
// Same fallback exception as setup: card may come before phone.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (!user.requires_card_verification && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1486,6 +1738,12 @@ export class AuthController extends PuterController {
// Kill switch — same semantics as /card-verification/setup.
if (confirmEvent.enabled === false) {
if (fallbackEligible)
console.warn(
'[card-verification/confirm] card verification disabled;' +
` fallback-eligible user ${user.uuid} remains` +
' phone-gated with no working verification path',
);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
@@ -1503,8 +1761,15 @@ export class AuthController extends PuterController {
return;
}
// A fallback card clears the phone gate too — the point of the
// fallback. `fallbackEligible &&` makes the invariant local instead
// of leaning on the 409 guard above: only a fallback user's card can
// ever clear a phone gate.
const clearedPhoneGate =
fallbackEligible && Boolean(user.requires_phone_verification);
await this.stores.user.update(user.id, {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
try {
@@ -1529,11 +1794,21 @@ export class AuthController extends PuterController {
'user.card_verified',
{ original_client_socket_id },
);
// The fallback cleared the phone gate too — tell phone-gate UIs.
if (clearedPhoneGate)
await this.services.socket?.send(
{ room: user.id },
'user.phone_verified',
{ original_client_socket_id },
);
} catch {
// ignore — best-effort
}
res.json({ card_verified: true });
res.json({
card_verified: true,
...(clearedPhoneGate ? { phone_verified: true } : {}),
});
}
// -- Password recovery -------------------------------------------
@@ -2032,6 +2307,8 @@ export class AuthController extends PuterController {
requires_email_confirmation: 0,
});
await this.stores.oidc.unlinkAllByUserId(user.id);
try {
this.clients.event?.emit(
'user.email-changed' as never,
@@ -2718,6 +2995,33 @@ export class AuthController extends PuterController {
await Promise.all([userPermGrantPromise, missingFSPathPromise]);
try {
const a = app as {
id?: number;
uid?: string;
index_url?: string | null;
owner_user_id?: number | null;
name?: string | null;
};
this.clients.event?.emit(
'puter.app.authenticated' as never,
{
app_uid,
app: {
id: a.id,
uid: a.uid,
index_url: a.index_url ?? null,
owner_user_id: a.owner_user_id ?? null,
name: a.name ?? null,
},
user_id: req.actor!.user?.id ?? null,
} as never,
{},
);
} catch {
// Fine if failed
}
res.json({ token, app_uid });
}
@@ -1992,3 +1992,111 @@ describe('FSController metadata.objectKey injection', () => {
expect(await streamToString(read.body)).toBe(attackerDecoy);
});
});
// ── associatedAppId entitlement gate ────────────────────────────────
//
// `associatedAppId` is client-supplied write metadata that's echoed back in
// legacy FS responses. Binding a file to another tenant's private app would
// turn `/stat` into an app-row enumeration oracle, so the write path drops
// any association the actor isn't entitled to make.
describe('FSController associatedAppId entitlement gate', () => {
// Seed an app row with a direct insert. `create` treats is_private as a
// read-only column, and going through the store would prime the cache —
// a raw insert leaves nothing cached so the gate's getById reads the DB.
const makeApp = async (
ownerUserId: number,
opts: { is_private?: boolean } = {},
): Promise<{ id: number }> => {
const uid = `app-${uuidv4()}`;
await server.clients.db.write(
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
VALUES (?, ?, ?, ?, ?, ?)`,
[
uid,
uid,
'Gate App',
'https://gate-app.puter.site/',
ownerUserId,
opts.is_private ? 1 : 0,
],
);
const row = (
await server.clients.db.read(
'SELECT id FROM apps WHERE uid = ?',
[uid],
)
)[0] as { id: number };
return { id: row.id };
};
// Write a file via the signed-write flow with the given associatedAppId
// and return the committed entry's stored associatedAppId.
const writeWithAssociation = async (
actor: Actor,
path: string,
associatedAppId: number,
): Promise<number | null> => {
const startRes = makeRes();
await withActor(actor, () =>
controller.startBatchWrites(
makeReq<SignedWriteRequest[]>({
body: [{ fileMetadata: { path, size: 3, associatedAppId } }],
actor,
}),
startRes.res,
),
);
const [started] = startRes.captured.body as SignedWriteResponse[];
const completeRes = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: [{ uploadId: started.sessionId }],
actor,
}),
completeRes.res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(path);
return entry?.associatedAppId ?? null;
};
it("drops an association to another tenant's private app", async () => {
const victim = await makeUser();
const attacker = await makeUser();
const victimApp = await makeApp(victim.userId, { is_private: true });
const stored = await writeWithAssociation(
attacker.actor,
`/${attacker.actor.user!.username}/Documents/probe.txt`,
victimApp.id,
);
expect(stored).toBeNull();
});
it('keeps an association to a public app the actor does not own', async () => {
const owner = await makeUser();
const other = await makeUser();
const publicApp = await makeApp(owner.userId, { is_private: false });
const stored = await writeWithAssociation(
other.actor,
`/${other.actor.user!.username}/Documents/public-assoc.txt`,
publicApp.id,
);
expect(stored).toBe(publicApp.id);
});
it('keeps an association to the actor’s own private app', async () => {
const owner = await makeUser();
const ownApp = await makeApp(owner.userId, { is_private: true });
const stored = await writeWithAssociation(
owner.actor,
`/${owner.actor.user!.username}/Documents/own-assoc.txt`,
ownApp.id,
);
expect(stored).toBe(ownApp.id);
});
});
+50 -4
View File
@@ -119,6 +119,8 @@ export class FSController extends PuterController {
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
requestBody.fileMetadata,
requestBody,
undefined,
userId,
);
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
pathAlreadyNormalized: true,
@@ -183,6 +185,7 @@ export class FSController extends PuterController {
normalizedRequestBody.fileMetadata,
normalizedRequestBody,
appUidLookupCache,
userId,
);
return normalizedRequestBody;
}),
@@ -364,6 +367,8 @@ export class FSController extends PuterController {
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
requestBody.fileMetadata,
requestBody,
undefined,
userId,
);
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
pathAlreadyNormalized: true,
@@ -497,6 +502,7 @@ export class FSController extends PuterController {
item.fileMetadata,
item,
appUidLookupCache,
userId,
),
})),
),
@@ -718,6 +724,7 @@ export class FSController extends PuterController {
normalizedRequestBody.fileMetadata,
normalizedRequestBody,
appUidLookupCache,
userId,
);
return normalizedRequestBody;
}),
@@ -1695,6 +1702,7 @@ export class FSController extends PuterController {
fileMetadata: FSEntryWriteInput,
fallbackSource?: unknown,
appUidLookupCache?: Map<string, Promise<number | null>>,
actorUserId?: number,
): Promise<FSEntryWriteInput> {
const metadataRecord = this.#toObjectRecord(fileMetadata);
const fallbackRecord = this.#toObjectRecord(fallbackSource);
@@ -1708,10 +1716,11 @@ export class FSController extends PuterController {
),
);
if (associatedAppId !== undefined) {
return {
...fileMetadata,
return this.#withAssociatedAppId(
fileMetadata,
associatedAppId,
};
actorUserId,
);
}
const appUid = this.#firstDefined(
@@ -1745,9 +1754,46 @@ export class FSController extends PuterController {
if (resolvedAppId === null) {
return fileMetadata;
}
return this.#withAssociatedAppId(
fileMetadata,
resolvedAppId,
actorUserId,
);
}
/**
* Bind `associatedAppId` onto the write input only when the actor is
* entitled to reference that app. `associatedAppId` is client-supplied
* and never trusted for authz, but it's echoed back in legacy FS
* responses — so an attacker could plant another tenant's private app id
* to confirm the row exists (an enumeration oracle) and harvest its
* metadata. Allow binding to public apps (their existence isn't secret)
* or to apps the actor owns; drop the association otherwise so the file
* simply carries no associated app.
*
* `#resolveWriteFileMetadata` has already copied the raw client value onto
* `fileMetadata`, so a dropped association must be actively stripped — not
* merely left unset.
*/
async #withAssociatedAppId(
fileMetadata: FSEntryWriteInput,
appId: number,
actorUserId?: number,
): Promise<FSEntryWriteInput> {
const app = await this.stores.app.getById(appId);
const isPrivate =
!!app && (Boolean(app.is_private) || Boolean(app.protected));
const isOwner =
actorUserId !== undefined &&
this.#toNumber(app?.owner_user_id) === actorUserId;
if (!app || (isPrivate && !isOwner)) {
const { associatedAppId: _dropped, ...rest } =
fileMetadata as unknown as Record<string, unknown>;
return rest as unknown as FSEntryWriteInput;
}
return {
...fileMetadata,
associatedAppId: resolvedAppId,
associatedAppId: appId,
};
}
@@ -765,6 +765,121 @@ describe('LegacyFSController.copy', () => {
`/${username}/Pictures/renamed-copy`,
);
});
it('copies an empty file (no backing S3 object) without erroring', async () => {
// Empty files created via /touch have size 0 and no S3 object —
// bucket is null. Copy must clone them as empty-file entries rather
// than issuing a CopyObject, which would throw S3 NoSuchKey.
const { actor } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/empty.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: src }, actor }),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.copy(
makeReq({
body: { source: src, destination: `/${username}/Pictures` },
actor,
}),
res,
),
);
const body = captured.body as Array<{ copied: { path: string } }>;
expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`);
const copied = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures/empty.txt`,
);
expect(copied).not.toBeNull();
expect(copied!.size).toBe(0);
// Original is untouched.
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
});
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
// A real file whose backing S3 object has vanished keeps a non-null
// bucket, so it isn't an empty file. CopyObject would throw S3
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
// rather than bubbling a 500. Mirrors readContent's ghost handling.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/ghost.txt`;
const content = Buffer.from('i will be deleted from s3');
await server.services.fs.write(userId, {
fileMetadata: {
path: src,
size: content.byteLength,
contentType: 'text/plain',
},
fileContent: content,
});
// Delete the backing S3 object directly, leaving the DB row behind.
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
await server.stores.s3Object.deleteObject(
server.stores.s3Object.resolveBucket(entry.bucket),
entry.uuid,
server.stores.s3Object.resolveRegion(entry.bucketRegion),
);
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.copy(
makeReq({
body: {
source: src,
destination: `/${username}/Pictures`,
},
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
// The ghost handler removed the orphaned source row.
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
// No partial copy was left behind.
expect(
await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures/ghost.txt`,
),
).toBeNull();
});
it('reads an empty file as empty content without deleting it', async () => {
// Reading an empty file (no S3 object) must not throw NoSuchKey nor
// trip the ghost-file cleanup, which would delete the entry.
const { actor } = await makeUser();
const username = actor.user!.username!;
const src = `/${username}/Documents/readme-empty.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: src }, actor }),
makeRes().res,
),
);
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
const download = await server.services.fs.readContent(entry);
const chunks: Buffer[] = [];
for await (const chunk of download.body) {
chunks.push(Buffer.from(chunk as Uint8Array));
}
expect(Buffer.concat(chunks).byteLength).toBe(0);
expect(download.contentLength).toBe(0);
// The entry must still exist — the ghost handler must NOT have run.
expect(
await server.stores.fsEntry.getEntryByPath(src),
).not.toBeNull();
});
});
// ── move ────────────────────────────────────────────────────────────
@@ -158,8 +158,7 @@ export class LegacyFSController extends PuterController {
router.get('/get-launch-apps', apiOptions, async (req, res) => {
const recommendedSvc = this.services.recommendedApps as unknown as
| { getRecommendedApps?: () => Promise<unknown[]> }
| undefined;
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
const recommended = recommendedSvc?.getRecommendedApps
? await recommendedSvc.getRecommendedApps()
: [];
@@ -194,6 +193,11 @@ export class LegacyFSController extends PuterController {
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// An app with no owner isn't owned by a Puter user —
// it's an "external" (origin-bootstrapped) app.
external:
app.owner_user_id == null ||
app.owner_user_id === '',
});
}
}
@@ -555,9 +559,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -973,8 +975,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1515,10 +1516,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -2196,120 +2194,6 @@ export class LegacyFSController extends PuterController {
}
// -- Helpers ---------------------------------------------------------
#parsePositiveIntegerQuery(
query: Record<string, unknown>,
key: string,
message: string,
): number | undefined {
const value = query[key];
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new HttpError(400, message, { legacyCode: 'bad_request' });
}
return parsed;
}
#parseNonNegativeIntegerQuery(
query: Record<string, unknown>,
key: string,
message: string,
): number | undefined {
const value = query[key];
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new HttpError(400, message, { legacyCode: 'bad_request' });
}
return parsed;
}
#normalizeRangeHeader(rangeHeader: string): string | undefined {
const firstRange = rangeHeader.includes(',')
? rangeHeader.split(',')[0]?.trim()
: rangeHeader.trim();
if (!firstRange) return undefined;
const matches = firstRange.match(/^bytes=(\d+)-(\d*)$/);
if (!matches) return undefined;
const [, start, end] = matches;
return end ? `bytes=${start}-${end}` : `bytes=${start}-`;
}
#pipeLimitedLines(
source: NodeJS.ReadableStream,
res: Response,
lineCount: number,
): void {
let remainingLines = lineCount;
let isClosed = false;
const closeSource = () => {
if (isClosed) return;
isClosed = true;
if ('destroy' in source && typeof source.destroy === 'function') {
source.destroy();
}
};
source.on('error', (err) => {
if (!isClosed) {
isClosed = true;
res.destroy(err);
}
});
res.on('close', () => {
closeSource();
});
source.on('data', (chunk: Buffer | string) => {
if (isClosed) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
let endIndex = buffer.length;
for (let index = 0; index < buffer.length; index++) {
if (buffer[index] !== 0x0a) continue;
remainingLines -= 1;
if (remainingLines === 0) {
endIndex = index + 1;
break;
}
}
if (endIndex > 0) {
const canContinue = res.write(buffer.subarray(0, endIndex));
if (!canContinue) {
source.pause();
res.once('drain', () => {
if (!isClosed) {
source.resume();
}
});
}
}
if (endIndex !== buffer.length) {
res.end();
closeSource();
}
});
source.on('end', () => {
if (!isClosed) {
isClosed = true;
res.end();
}
});
}
#requireActor(req: Request) {
const actor = req.actor;
if (!actor) {
@@ -0,0 +1,102 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { loadLegacyAssociatedApps } from './legacyFsHelpers.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
const entryWithApp = (associatedAppId: number): FSEntry =>
({ associatedAppId }) as unknown as FSEntry;
const appRow = (overrides: Record<string, unknown>): Record<string, unknown> => ({
id: 1,
uid: 'app-1',
owner_user_id: 99,
app_owner: 99,
icon: 'icon',
name: 'an-app',
title: 'An App',
description: 'desc',
godmode: 1,
maximize_on_start: 1,
index_url: 'https://an-app.puter.site/',
background: 1,
metadata: { secret: true },
is_private: 0,
protected: 0,
...overrides,
});
const fakeStore = (row: Record<string, unknown>) => ({
getByIds: async (ids: number[]) =>
new Map(ids.map((id) => [id, { ...row, id }])),
});
describe('loadLegacyAssociatedApps associated_app redaction', () => {
it('never leaks owner identifiers, even for public apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 0 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app).not.toHaveProperty('owner_user_id');
expect(app).not.toHaveProperty('app_owner');
});
it('passes through hosting + capability fields for public apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 0, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app.index_url).toBe('https://an-app.puter.site/');
expect(app.godmode).toBe(1);
expect(app.maximize_on_start).toBe(1);
});
it('redacts hosting + capability fields for private apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 1, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
// Existence + display fields still surface...
expect(app.uid).toBe('app-1');
expect(app.name).toBe('an-app');
expect(app.is_private).toBe(1);
// ...but the sensitive bits are stripped.
expect(app.index_url).toBeNull();
expect(app.godmode).toBe(0);
expect(app.maximize_on_start).toBe(0);
expect(app.background).toBe(0);
expect(app.metadata).toBeNull();
expect(app).not.toHaveProperty('owner_user_id');
});
it('redacts the same fields for protected apps', async () => {
const out = await loadLegacyAssociatedApps(
fakeStore(appRow({ is_private: 0, protected: 1, godmode: 1 })),
[entryWithApp(1)],
);
const app = out.get(1)!;
expect(app.protected).toBe(1);
expect(app.index_url).toBeNull();
expect(app.godmode).toBe(0);
});
});
+18 -7
View File
@@ -274,21 +274,33 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
* because the v1 wire contract emits them that way and existing clients key
* off it. Other columns pass through as-is — `metadata` is already parsed.
*
* Two redactions, because `associatedAppId` can name an app the actor does
* not own (the column is client-writable display metadata, never trusted for
* authz — see FSController) and may point cross-tenant:
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
* they're internal user references with no client-side display use.
* - For private or protected apps, the direct hosting URL and launch
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
* withholds `index_url` from callers without read entitlement. Display
* fields (name, icon, title) still pass through so the GUI can label the
* file's associated app.
*/
function mapAppForLegacyAssociatedApp(
app: Record<string, unknown>,
): Record<string, unknown> {
const gated = Boolean(app.is_private) || Boolean(app.protected);
return {
id: app.id,
uid: app.uid,
owner_user_id: app.owner_user_id,
icon: app.icon,
name: app.name,
title: app.title,
description: app.description,
godmode: toIntBool(app.godmode),
maximize_on_start: toIntBool(app.maximize_on_start),
index_url: app.index_url,
godmode: gated ? 0 : toIntBool(app.godmode),
maximize_on_start: gated ? 0 : toIntBool(app.maximize_on_start),
index_url: gated ? null : app.index_url,
approved_for_listing: toIntBool(app.approved_for_listing),
approved_for_opening_items: toIntBool(app.approved_for_opening_items),
approved_for_incentive_program: toIntBool(
@@ -297,9 +309,8 @@ function mapAppForLegacyAssociatedApp(
timestamp: app.timestamp ?? null,
last_review: app.last_review ?? null,
tags: app.tags ?? null,
app_owner: app.app_owner ?? null,
background: toIntBool(app.background),
metadata: app.metadata ?? null,
background: gated ? 0 : toIntBool(app.background),
metadata: gated ? null : (app.metadata ?? null),
protected: toIntBool(app.protected),
is_private: toIntBool(app.is_private),
};
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
expect(html).toContain('Puter');
});
it('exposes disable_temp_users to the GUI when signups are disabled', async () => {
const homepageConfig = server.controllers.homepage.config as {
disable_user_signup?: boolean;
};
const prev = homepageConfig.disable_user_signup;
homepageConfig.disable_user_signup = true;
const { res, captured } = makeRes();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prev;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
const homepageConfig = server.controllers.homepage.config as {
disable_user_signup?: boolean;
gui_params?: Record<string, unknown>;
};
const prevFlag = homepageConfig.disable_user_signup;
const prevGuiParams = homepageConfig.gui_params;
homepageConfig.disable_user_signup = false;
homepageConfig.gui_params = {
...prevGuiParams,
disable_temp_users: true,
};
const { res, captured } = makeRes();
try {
await callRoute('get', '/', makeReq({ path: '/' }), res);
} finally {
homepageConfig.disable_user_signup = prevFlag;
homepageConfig.gui_params = prevGuiParams;
}
expect(String(captured.body)).toContain('"disable_temp_users":true');
});
it('still serves the shell when an authenticated actor is present', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
@@ -173,12 +210,14 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
@@ -82,6 +82,9 @@ export class HomepageController extends PuterController {
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
router.get('/@:username', {}, (req, res) => sendShell(req, res));
@@ -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]
@@ -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,
});
});
});
@@ -42,6 +42,13 @@ declare global {
tokenAuthFailed?: boolean;
/**
* Set when a token authenticated but its app is on the origin
* blocklist. The auth probe leaves `actor` unset; gates translate
* this into a 403 `app_blocked`.
*/
appBlocked?: { reason?: string };
requiresReauth?: {
reason: 'token_v1' | 'session_revoked' | 'session_expired';
auth_id?: string;
@@ -545,30 +545,10 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
});
});
// ── Reauth signal + KV counters ─────────────────────────────────────
// ── Reauth signal ───────────────────────────────────────────────────
describe('createAuthProbe — reauth signal', () => {
/** Capture KV increments without a real store. */
const makeKvStub = () => {
const calls: Array<{
key: string;
pathAndAmountMap: Record<string, number>;
}> = [];
return {
calls,
store: {
incr: async (args: {
key: string;
pathAndAmountMap: Record<string, number>;
}) => {
calls.push(args);
return { res: {} as Record<string, number>, usage: 0 };
},
},
};
};
it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => {
it('sets requiresReauth with a signed token for a legacy v1 token', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
@@ -576,11 +556,7 @@ describe('createAuthProbe — reauth signal', () => {
// The probe must still set requiresReauth; gate emits 401.
actor: { user: { uuid: 'u-legacy' } },
} as never);
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
@@ -590,109 +566,36 @@ describe('createAuthProbe — reauth signal', () => {
auth_id: 'u-legacy',
reauth_token: 'reauth-jwt:u-legacy',
});
// Both increments fire under the same day-bucketed key.
expect(kv.calls).toHaveLength(1);
expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/);
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.token_v1': 1,
});
});
it('sets requiresReauth and counts reauth.session_revoked', async () => {
it('sets requiresReauth for a revoked session', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
});
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer tok' } }),
);
expect(req.requiresReauth?.reason).toBe('session_revoked');
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.session_revoked': 1,
});
});
it('sets requiresReauth and counts reauth.session_expired', async () => {
it('sets requiresReauth for an expired session', async () => {
const stub = makeStubAuth();
stub.setNextResult({
reauth: { reason: 'session_expired', auth_id: 'u-1' },
});
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer tok' } }),
);
expect(req.requiresReauth?.reason).toBe('session_expired');
expect(kv.calls[0].pathAndAmountMap).toEqual({
v1: 1,
'reauth.session_expired': 1,
});
});
it('counts v2 verify (no reauth) for a healthy token', async () => {
it('attaches an actor and no reauth for a healthy v2 token', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(req.actor).toBeTruthy();
expect(req.requiresReauth).toBeUndefined();
expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 });
});
it('does not increment counters when no token is presented', async () => {
const stub = makeStubAuth();
const kv = makeKvStub();
const probe = createAuthProbe({
authService: stub.service,
kvStore: kv.store,
});
await runProbe(probe, makeReq({}));
// Anonymous-OK routes pay no metrics cost.
expect(kv.calls).toHaveLength(0);
});
it('absorbs KV failures — never rejects on the hot path', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
// Failing KV: increments throw. Probe must still succeed.
const failingKv = {
incr: async () => {
throw new Error('kv unavailable');
},
};
const probe = createAuthProbe({
authService: stub.service,
kvStore: failingKv,
});
const { req, next } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(next).toHaveBeenCalledWith();
expect(req.actor).toBeTruthy();
});
it('works without a kvStore (counters become no-ops)', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
// Production may not always pass a KV — fresh installs without
// dynamo wired still need a functioning probe.
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
@@ -702,6 +605,17 @@ describe('createAuthProbe — reauth signal', () => {
expect(req.requiresReauth).toBeUndefined();
});
it('never rejects on the hot path', async () => {
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
const probe = createAuthProbe({ authService: stub.service });
const { req, next } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
);
expect(next).toHaveBeenCalledWith();
expect(req.actor).toBeTruthy();
});
it('logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event', async () => {
// The log line is the human-facing forensic counterpart to the
// KV counter. Asserting the exact shape so ops can `grep
+8 -25
View File
@@ -22,7 +22,6 @@ import type {
AuthService,
ReauthReason,
} from '../../../services/auth/AuthService';
import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore';
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
// wherever this middleware is imported.
@@ -32,25 +31,8 @@ interface AuthProbeOptions {
authService: AuthService;
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
cookieName?: string;
kvStore?: SystemKVStore;
}
const authV2MetricsKey = (): string => {
const now = new Date();
const yyyy = now.getUTCFullYear();
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
const dd = String(now.getUTCDate()).padStart(2, '0');
return `auth-v2:metrics:${yyyy}-${mm}-${dd}`;
};
const bumpCounter = (
kv: Pick<SystemKVStore, 'incr'> | undefined,
pathAndAmountMap: Record<string, number>,
): void => {
if (!kv) return;
kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {});
};
/**
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
* every request, tries to locate a token in the usual places, and — if one
@@ -70,7 +52,7 @@ const bumpCounter = (
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
const { authService, cookieName, kvStore } = opts;
const { authService, cookieName } = opts;
return async (req, _res, next): Promise<void> => {
// If something upstream already attached an actor, respect it.
if (req.actor) {
@@ -94,10 +76,6 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
});
if (result.reauth) {
bumpCounter(kvStore, {
v1: 1,
[`reauth.${result.reauth.reason}`]: 1,
});
// Bind a short-lived JWT proving the rejected session
// identified this auth_id. The GUI echoes this back on
// /login or /signup; the raw auth_id is informational
@@ -113,8 +91,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
console.info(
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
);
} else if (result.actor) {
bumpCounter(kvStore, { v2: 1 });
}
if (result.blocked) {
// App is on the origin blocklist: leave `actor` unset so gates
// reject. `appBlocked` lets the gate emit a clear 403 instead
// of the generic "token failed" 401.
req.appBlocked = { reason: result.blocked.reason };
}
if (result.actor) {
+10
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;
@@ -268,6 +268,7 @@ export class ClaudeProvider implements IChatProvider {
// Other models require temperature=1 when thinking is enabled.
const omitsTemperature = [
'claude-fable-5',
'claude-sonnet-5',
'claude-opus-4-7',
'claude-opus-4-8',
].includes(modelUsed.id);
@@ -278,6 +279,7 @@ export class ClaudeProvider implements IChatProvider {
: (temperature ?? 0);
const supportsEffort = [
'claude-fable-5',
'claude-sonnet-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
@@ -50,6 +50,35 @@ export const CLAUDE_MODELS: IChatModel[] = [
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-sonnet-5',
id: 'claude-sonnet-5',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-06-30',
aliases: [
'claude-sonnet',
'claude-sonnet-latest',
'claude-sonnet-5-latest',
'claude-sonnet-5',
'anthropic/claude-sonnet-5',
],
name: 'Claude Sonnet 5',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 300,
ephemeral_5m_input_tokens: 300 * 1.25,
ephemeral_1h_input_tokens: 300 * 2,
cache_read_input_tokens: 300 * 0.1,
output_tokens: 1500,
},
context: 1000000,
max_tokens: 64000,
},
{
puterId: 'anthropic:anthropic/claude-opus-4-8',
id: 'claude-opus-4-8',
@@ -119,8 +148,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
knowledge: '2025-08',
release_date: '2026-02-17',
aliases: [
'claude-sonnet-latest',
'claude-sonnet',
'claude-sonnet-4-6-latest',
'claude-sonnet-4.6',
'claude-sonnet-4-6',
@@ -55,20 +55,30 @@ import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
// driver-level tests — what we care about is the driver's routing.
// Each `generate` mock resolves to a sentinel URL the test inspects.
const { openaiImagesGenerateMock } = vi.hoisted(() => ({
const { openaiImagesGenerateMock, openaiImagesEditMock } = vi.hoisted(() => ({
openaiImagesGenerateMock: vi.fn(),
openaiImagesEditMock: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
) {
this.images = { generate: openaiImagesGenerateMock };
this.images = {
generate: openaiImagesGenerateMock,
edit: openaiImagesEditMock,
};
// xAI provider reaches its JSON edit endpoint through the SDK's post().
this.post = vi.fn();
this.chat = { completions: { create: vi.fn() } };
this.moderations = { create: vi.fn() };
this.responses = { create: vi.fn() };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
return {
OpenAI: OpenAICtor,
default: { OpenAI: OpenAICtor },
toFile: vi.fn(async () => ({ __file: true })),
};
});
const { googleAIGenerateContentMock, googleAIGenerateImagesMock } = vi.hoisted(
@@ -148,6 +158,7 @@ afterAll(async () => {
beforeEach(() => {
openaiImagesGenerateMock.mockReset();
openaiImagesEditMock.mockReset();
googleAIGenerateContentMock.mockReset();
googleAIGenerateImagesMock.mockReset();
togetherImagesGenerateMock.mockReset();
@@ -176,9 +187,9 @@ describe('ImageGenerationDriver.generate authentication', () => {
it('throws 401 when no actor is on the request context', async () => {
await expect(
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
).rejects.toMatchObject({ statusCode: 401 });
});
@@ -206,10 +217,10 @@ describe('ImageGenerationDriver model catalog', () => {
const all = await driver.models();
// Every catalog id from at least one provider must be reachable.
const ids = all.map((m) => m.id);
// OpenAI catalog: dall-e-2 should be present (lowercased by buildModelMap).
expect(ids).toContain('dall-e-2');
// xAI catalog: grok-2-image should be present.
expect(ids).toContain('grok-2-image');
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
expect(ids).toContain('gpt-image-1-mini');
// xAI catalog: grok-imagine-image should be present.
expect(ids).toContain('grok-imagine-image');
});
it('list() returns ids/puterIds sorted', async () => {
@@ -224,32 +235,35 @@ describe('ImageGenerationDriver model catalog', () => {
costValue: number;
source: string;
}>;
// dall-e-2 has a 256x256 cost line — must surface in reportedCosts.
const dalle256 = reported.find(
(r) => r.usageType === 'openai-image-generation:dall-e-2:256x256',
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
const gptLine = reported.find(
(r) =>
r.usageType ===
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
);
expect(dalle256).toBeDefined();
expect(dalle256?.costValue).toBe(
OPEN_AI_IMAGE_GENERATION_MODELS.find((m) => m.id === 'dall-e-2')!
.costs['256x256'],
expect(gptLine).toBeDefined();
expect(gptLine?.costValue).toBe(
OPEN_AI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'gpt-image-1-mini',
)!.costs['low:1024x1024'],
);
expect(dalle256?.source).toBe('driver:aiImage/openai-image-generation');
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
});
});
// ── Provider routing ────────────────────────────────────────────────
describe('ImageGenerationDriver.generate provider routing', () => {
it('routes a known dall-e-2 model id to the OpenAI image provider', async () => {
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
const result = await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -260,34 +274,34 @@ describe('ImageGenerationDriver.generate provider routing', () => {
expect(replicateRunMock).not.toHaveBeenCalled();
});
it('routes a known grok-2-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://xai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'hi',
} as never),
);
// xAI's provider also uses the OpenAI mock — assert via the call args.
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.model).toBe('grok-2-image');
expect(sent.model).toBe('grok-imagine-image');
expect(sent.prompt).toBe('hi');
});
it('lowercases model lookups so case variants resolve (DALL-E-2 → dall-e-2)', async () => {
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'DALL-E-2',
model: 'GPT-Image-1-Mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -322,16 +336,16 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
width: 256,
height: 256,
width: 1024,
height: 1536,
} as never),
);
// Provider received a size derived from the ratio normalization.
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.size).toBe('256x256');
expect(sent.size).toBe('1024x1536');
});
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
@@ -369,9 +383,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
await withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
);
@@ -381,7 +395,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
expect(aiLogCall).toBeDefined();
const [, payload] = aiLogCall!;
const p = payload as Record<string, unknown>;
expect(p.model_used).toBe('dall-e-2');
expect(p.model_used).toBe('gpt-image-1-mini');
expect(p.service_used).toBe('openai-image-generation');
// completionId is a fresh uuid-style string per call.
expect(typeof p.completionId).toBe('string');
@@ -395,9 +409,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
await expect(
withActor(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
} as never),
),
).rejects.toThrow();
@@ -424,7 +438,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/',
} as never),
@@ -438,7 +452,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/image.png',
} as never),
@@ -455,7 +469,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/somedir/image.png',
} as never),
@@ -480,7 +494,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -509,7 +523,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '~/images/out.png',
} as never),
@@ -541,7 +555,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
const result = await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/photos/out.png',
} as never),
@@ -584,7 +598,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -602,7 +616,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
puter_output_path: '/noone/dir/img.png',
} as never),
+102
View File
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Shared helpers for `input_images` (image-to-image) handling across image
* providers. `input_images` is the canonical, cross-provider field; an entry
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
* API needs base64 use these helpers to normalize URLs server-side (via the
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
* xAI) pass them through untouched.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { secureFetch } from '../../util/secureHttp.js';
import type { IGenerateParams } from './types.js';
export function isHttpUrl(s: string): boolean {
return s.startsWith('http://') || s.startsWith('https://');
}
/**
* Resolve the single input image for providers that only support one.
* Throws 400 if `input_images` carries more than one entry. Returns the
* chosen image string (URL / data-URI / raw base64) or undefined.
*/
export function resolveSingleInputImage(
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
providerLabel: string,
): string | undefined {
const imgs = params.input_images;
if (imgs && imgs.length > 1) {
throw new HttpError(
400,
`${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
{ legacyCode: 'bad_request' },
);
}
return params.input_image ?? imgs?.[0];
}
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
export function parseDataUri(
s: string,
): { base64: string; mime: string } | null {
const m = DATA_URI_PATTERN.exec(s);
if (!m) return null;
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
}
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
export async function fetchImageAsBase64(
url: string,
): Promise<{ base64: string; mime: string }> {
const res = await secureFetch(url);
if (!res.ok) {
throw new HttpError(
400,
`Failed to fetch input image (status ${res.status})`,
{ legacyCode: 'bad_request' },
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const mime =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
return { base64: buffer.toString('base64'), mime };
}
/**
* Normalize any input-image string to a base64 data-URI:
* • http(s) URL → fetched via secureFetch
* • data-URI → returned as-is
* • raw base64 → wrapped with `mimeHint` (default image/png)
*/
export async function toBase64DataUri(
img: string,
mimeHint?: string,
): Promise<string> {
if (img.startsWith('data:')) return img;
if (isHttpUrl(img)) {
const { base64, mime } = await fetchImageAsBase64(img);
return `data:${mime};base64,${base64}`;
}
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
}
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
fetchImageAsBase64Mock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
fetchImageAsBase64: fetchImageAsBase64Mock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -81,6 +91,7 @@ const makeProvider = (
);
beforeEach(() => {
fetchImageAsBase64Mock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
);
});
});
// ── input_images (canonical image-to-image field) ──────────────────
describe('CloudflareImageProvider.generate input_images', () => {
const klein9bWith = (extra: Record<string, unknown>) => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from([1, 2, 3]).buffer, {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
return withTestActor(() =>
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 2000, h: 1000 },
...extra,
} as never),
);
};
const hasInputCostLine = () => {
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
return (entries as Array<{ usageType: string }>).some((e) =>
e.usageType.endsWith(':input_image_mp'),
);
};
it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
expect(hasInputCostLine()).toBe(true);
});
it('maps a singular input_image to the input image', async () => {
await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
expect(hasInputCostLine()).toBe(true);
});
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
fetchImageAsBase64Mock.mockResolvedValueOnce({
base64: 'AAAA',
mime: 'image/png',
});
await klein9bWith({ input_images: ['https://example.com/in.png'] });
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
'https://example.com/in.png',
);
expect(hasInputCostLine()).toBe(true);
});
it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 1024, h: 1024 },
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
});
});
@@ -29,6 +29,11 @@ import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
CloudflareImageModel,
} from './models.js';
import {
fetchImageAsBase64,
isHttpUrl,
resolveSingleInputImage,
} from '../../inputImage.js';
type CloudflareGenerateParams = IGenerateParams & {
steps?: number;
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
});
}
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
// Cloudflare accepts a single input image; a URL is fetched to base64
// server-side (SSRF-guarded) since the API has no URL field.
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
if (singleInput) {
options.image ??= isHttpUrl(singleInput)
? (await fetchImageAsBase64(singleInput)).base64
: singleInput;
}
const steps = this.#resolveSteps(selectedModel, options);
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
hasInputImage:
@@ -71,6 +71,16 @@ vi.mock('@google/genai', () => {
return { GoogleGenAI };
});
// Stub the URL→data-URI normalizer so URL inputs stay offline; keep the rest real.
const { toBase64DataUriMock } = vi.hoisted(() => ({
toBase64DataUriMock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
toBase64DataUri: toBase64DataUriMock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -94,6 +104,7 @@ const makeProvider = () =>
beforeEach(() => {
generateContentMock.mockReset();
generateImagesMock.mockReset();
toBase64DataUriMock.mockReset();
googleAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
@@ -272,6 +283,31 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
expect(result).toBe('data:image/png;base64,BASE64IMG');
});
it('fetches an http(s) URL input and sends it as an inlineData part', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
toBase64DataUriMock.mockResolvedValueOnce(
'data:image/png;base64,URLBYTES',
);
await withTestActor(() =>
provider.generate({
model: 'gemini-2.5-flash-image',
prompt: 'add a hat',
input_images: ['https://example.com/in.png'],
}),
);
expect(toBase64DataUriMock).toHaveBeenCalledWith(
'https://example.com/in.png',
undefined,
);
const sent = generateContentMock.mock.calls[0]![0];
expect(sent.contents).toContainEqual({
inlineData: { mimeType: 'image/png', data: 'URLBYTES' },
});
});
it('throws 400 when the SDK returns no inline image data', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce({
@@ -31,6 +31,7 @@ import type {
IImageModel,
IImageProvider,
} from '../../types.js';
import { isHttpUrl, toBase64DataUri } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const MIME_SIGNATURES: Record<string, string> = {
@@ -107,6 +108,18 @@ export class GeminiImageProvider implements IImageProvider {
input_images = [input_image];
}
// Resolve any http(s) URL inputs to base64 data-URIs server-side
// (SSRF-guarded) so the rest of the flow only deals with inline data.
if (input_images?.length) {
input_images = await Promise.all(
input_images.map((img) =>
isHttpUrl(img)
? toBase64DataUri(img, input_image_mime_type)
: img,
),
);
}
// Validate input images have detectable MIME types
if (input_images?.length) {
for (const img of input_images) {
@@ -39,6 +39,8 @@ export const GEMINI_ESTIMATED_IMAGE_TOKENS: Record<string, number> = {
'gemini-3.1-flash-image-preview:1K': 1120,
'gemini-3.1-flash-image-preview:2K': 1680,
'gemini-3.1-flash-image-preview:4K': 2520,
'gemini-3.1-flash-lite-image:1K': 1120,
};
export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
@@ -152,6 +154,43 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
'1K:1x1': 6.7,
},
},
{
puterId: 'google:google/gemini-3.1-flash-lite-image',
id: 'gemini-3.1-flash-lite-image',
name: 'Gemini 3.1 Flash Lite Image',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1K:1x1',
index_input_cost_key: 'input',
aliases: [
'gemini-3.1-flash-lite-image',
'google/gemini-3.1-flash-lite-image',
'google:google/gemini-3.1-flash-lite-image',
],
allowedQualityLevels: ['1K'], // 2K and 4K are unsupported
allowedRatios: [
{ w: 1, h: 1 },
{ w: 1, h: 4 },
{ w: 1, h: 8 },
{ w: 2, h: 3 },
{ w: 3, h: 2 },
{ w: 3, h: 4 },
{ w: 4, h: 1 },
{ w: 4, h: 3 },
{ w: 4, h: 5 },
{ w: 5, h: 4 },
{ w: 8, h: 1 },
{ w: 9, h: 16 },
{ w: 16, h: 9 },
{ w: 21, h: 9 },
],
costs: {
input: 25, // $0.25 per 1M input tokens (text/image)
output: 150, // $1.50 per 1M output tokens (text and thinking)
output_image: 3000, // $30.00 per 1M output image tokens
'1K:1x1': 3.36, // 1120 tokens @ $30/1M = $0.0336 per 1K image
},
},
// -- Imagen models (use generateImages API) --
{
@@ -20,8 +20,8 @@
/**
* Integration test for the OpenAI image generation provider.
*
* Uses `dall-e-2` at 256x256 — the cheapest OpenAI image
* configuration ($0.016/image). Skipped when
* Uses `gpt-image-1-mini` at low:1024x1024 — the cheapest OpenAI
* image configuration ($0.005/image). Skipped when
* `PUTER_TEST_AI_OPENAI_API_KEY` is unset.
*/
@@ -40,7 +40,7 @@ const ENV_VAR = 'PUTER_TEST_AI_OPENAI_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'OpenAiImageProvider (integration)',
() => {
it('returns an image url/data from dall-e-2 at 256x256', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
it('returns an image url/data from gpt-image-1-mini at low:1024x1024', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new OpenAiImageProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
@@ -48,9 +48,9 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'a tiny red dot on a white background',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
@@ -24,9 +24,9 @@
* redis) and constructs OpenAiImageProvider directly against the
* live wired `MeteringService`. The OpenAI SDK is mocked at the
* module boundary; that's the real network egress point. Covers the
* three model families the provider supports differently: dall-e-2/3
* (per-size pricing), gpt-image-* (token-priced), and gpt-image-2
* (open-ended size with the runtime-clamping normalizer).
* gpt-image-* models (token-priced), gpt-image-2 (open-ended size
* with the runtime-clamping normalizer), and image-to-image editing
* via `input_images` (the `images.edit` endpoint).
*/
import {
@@ -50,8 +50,15 @@ import { OpenAiImageProvider } from './OpenAiImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { generateMock, openAICtor } = vi.hoisted(() => ({
const { generateMock, editMock, toFileMock, openAICtor } = vi.hoisted(() => ({
generateMock: vi.fn(),
editMock: vi.fn(),
toFileMock: vi.fn(async (data: unknown, name: unknown, opts: unknown) => ({
__file: true,
name,
type: (opts as { type?: string } | undefined)?.type,
data,
})),
openAICtor: vi.fn(),
}));
@@ -61,12 +68,26 @@ vi.mock('openai', () => {
opts: unknown,
) {
openAICtor(opts);
this.images = { generate: generateMock };
this.images = { generate: generateMock, edit: editMock };
this.chat = { completions: { create: vi.fn() } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
return {
OpenAI: OpenAICtor,
default: { OpenAI: OpenAICtor },
toFile: toFileMock,
};
});
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
fetchImageAsBase64Mock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
fetchImageAsBase64: fetchImageAsBase64Mock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -88,6 +109,8 @@ const makeProvider = () =>
beforeEach(() => {
generateMock.mockReset();
editMock.mockReset();
fetchImageAsBase64Mock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -113,9 +136,16 @@ describe('OpenAiImageProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('OpenAiImageProvider model catalog', () => {
it('returns dall-e-2 as the default', () => {
it('returns gpt-image-1-mini as the default', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe('dall-e-2');
expect(provider.getDefaultModel()).toBe('gpt-image-1-mini');
});
it('no longer exposes any dall-e models', () => {
const provider = makeProvider();
expect(
provider.models().some((m) => m.id.startsWith('dall-e')),
).toBe(false);
});
it('exposes the static OPEN_AI_IMAGE_GENERATION_MODELS list verbatim', () => {
@@ -151,25 +181,6 @@ describe('OpenAiImageProvider.generate argument validation', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when DALL-E model + invalid quality+size combo cannot be priced', async () => {
const provider = makeProvider();
// dall-e-3 has no `2048x2048` cost entry; allowedRatios will snap to
// 1024x1024 first allowed entry though, so to trigger this we use a
// model with allowedRatios containing the mismatched ratio. We pass
// dall-e-2 with 256x256 but inject a hd quality (no hd:256 cost key).
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
prompt: 'hi',
ratio: { w: 256, h: 256 },
quality: 'hd',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateMock).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
@@ -181,9 +192,9 @@ describe('OpenAiImageProvider.generate credit gate', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
),
).rejects.toMatchObject({ statusCode: 402 });
@@ -191,89 +202,48 @@ describe('OpenAiImageProvider.generate credit gate', () => {
});
});
// ── DALL-E request shape & metering ────────────────────────────────
// ── Output extraction ──────────────────────────────────────────────
describe('OpenAiImageProvider.generate DALL-E request shape', () => {
const dalleResponse = {
data: [{ url: 'https://oai.example/img.png' }],
usage: undefined, // DALL-E responses don't include usage details.
};
it('forwards model + size verbatim and snaps invalid ratios to the first allowedRatio', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
prompt: 'a tiny red dot',
ratio: { w: 999, h: 999 }, // not in allowedRatios; falls back to 256x256
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.model).toBe('dall-e-2');
expect(sent.size).toBe('256x256');
// DALL-E 2 doesn't accept `quality` so the provider must omit it
// unless it's the literal 'hd' override (DALL-E 3).
expect('quality' in sent).toBe(false);
describe('OpenAiImageProvider.generate output extraction', () => {
const gptResponse = (data: unknown) => ({
data,
usage: {
input_tokens: 100,
output_tokens: 800,
input_tokens_details: {
text_tokens: 100,
image_tokens: 0,
cached_tokens: 0,
},
},
});
it('forwards quality=hd only for DALL-E 3', async () => {
it('returns response.data[0].url when the SDK returns a url', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
await withTestActor(() =>
provider.generate({
model: 'dall-e-3',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
quality: 'hd',
}),
generateMock.mockResolvedValueOnce(
gptResponse([{ url: 'https://oai.example/img.png' }]),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.model).toBe('dall-e-3');
expect(sent.quality).toBe('hd');
});
it('returns response.data[0].url and meters one output line at the size cents rate', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(dalleResponse);
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
expect(result).toBe('https://oai.example/img.png');
// dall-e-2 256x256 cost = 1.6 cents → 1_600_000 microcents.
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const outputEntry = (
entries as Array<{ usageType: string; costOverride: number }>
).find((e) => e.usageType.endsWith(':output'));
expect(outputEntry?.usageType).toBe(
'openai:dall-e-2:256x256:output',
);
expect(outputEntry?.costOverride).toBe(Math.ceil(1.6 * 1_000_000));
});
it('falls back to a base64 data URL when SDK returns b64_json instead of url', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ b64_json: 'AAAA' }],
});
generateMock.mockResolvedValueOnce(gptResponse([{ b64_json: 'AAAA' }]));
const result = await withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
);
@@ -281,6 +251,120 @@ describe('OpenAiImageProvider.generate DALL-E request shape', () => {
});
});
// ── input_images / edit endpoint ───────────────────────────────────
describe('OpenAiImageProvider.generate input_images (edit endpoint)', () => {
const editResponse = {
data: [{ b64_json: 'AAAA' }],
usage: {
input_tokens: 600,
output_tokens: 800,
input_tokens_details: {
text_tokens: 40,
image_tokens: 560,
cached_tokens: 0,
},
},
};
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
it('routes input_images to images.edit (not generate) with image files set', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: [PNG, PNG],
}),
);
expect(generateMock).not.toHaveBeenCalled();
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
expect(sent.model).toBe('gpt-image-1');
expect(sent.prompt).toBe('add a hat');
// Two input images → array of uploadables.
expect(Array.isArray(sent.image)).toBe(true);
expect(sent.image).toHaveLength(2);
});
it('meters an :input line at the image_input token rate when the edit response reports image tokens', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: [PNG],
}),
);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const inputEntry = (
entries as Array<{ usageType: string; costOverride: number }>
).find((e) => e.usageType.endsWith(':input'));
expect(inputEntry?.usageType).toBe(
'openai:gpt-image-1:low:1024x1024:input',
);
// gpt-image-1: text_input=500, image_input=1000 (cents/1M tokens).
// 40 text + 560 image tokens → (40*500 + 560*1000)/1e6 cents.
const expectedCents = (40 * 500 + 560 * 1000) / 1_000_000;
expect(inputEntry?.costOverride).toBe(Math.ceil(expectedCents * 1_000_000));
});
it('folds singular input_image into the edit path with a single uploadable', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1-mini',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_image: PNG,
}),
);
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
// Single image → not wrapped in an array.
expect(Array.isArray(sent.image)).toBe(false);
expect((sent.image as { __file?: boolean }).__file).toBe(true);
});
it('fetches an http(s) URL input and sends the bytes to images.edit', async () => {
const provider = makeProvider();
editMock.mockResolvedValueOnce(editResponse);
fetchImageAsBase64Mock.mockResolvedValueOnce({
base64: 'iVBORw0KGgo=',
mime: 'image/png',
});
await withTestActor(() =>
provider.generate({
model: 'gpt-image-1',
prompt: 'add a hat',
ratio: { w: 1024, h: 1024 },
input_images: ['https://example.com/in.png'],
}),
);
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
'https://example.com/in.png',
);
expect(generateMock).not.toHaveBeenCalled();
expect(editMock).toHaveBeenCalledTimes(1);
const sent = editMock.mock.calls[0]![0];
expect((sent.image as { __file?: boolean }).__file).toBe(true);
});
});
// ── gpt-image (token-priced) request shape & metering ──────────────
describe('OpenAiImageProvider.generate gpt-image-* request shape', () => {
@@ -423,7 +507,7 @@ describe('OpenAiImageProvider.generate gpt-image-2 size normalizer', () => {
// ── Output extraction error ────────────────────────────────────────
describe('OpenAiImageProvider.generate output extraction', () => {
describe('OpenAiImageProvider.generate output extraction error', () => {
it('throws 400 when SDK returns no usable image data', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({ data: [{}] });
@@ -431,9 +515,9 @@ describe('OpenAiImageProvider.generate output extraction', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'dall-e-2',
model: 'gpt-image-1-mini',
prompt: 'hi',
ratio: { w: 256, h: 256 },
ratio: { w: 1024, h: 1024 },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -17,8 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import openai, { OpenAI } from 'openai';
import openai, { OpenAI, toFile } from 'openai';
import {
ImageEditParamsNonStreaming,
ImageGenerateParamsNonStreaming,
ImagesResponse,
} from 'openai/resources/images.js';
@@ -30,6 +31,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
interface OpenAIImageUsage {
@@ -44,7 +46,8 @@ interface OpenAIImageUsage {
/**
* OpenAI image generation provider for v2.
* Supports DALL-E 2/3 and GPT Image models.
* Supports the GPT Image models (gpt-image-1, -1-mini, -1.5, -2), including
* image-to-image editing via `input_images` (the `images.edit` endpoint).
*/
export class OpenAiImageProvider implements IImageProvider {
#meteringService: MeteringService;
@@ -59,6 +62,11 @@ export class OpenAiImageProvider implements IImageProvider {
'image_output',
];
// Rough per-image input token estimate for the up-front credit gate only.
// Actual billing uses the real `image_tokens` reported in the response.
// Mirrors the constant the Gemini image provider uses.
static #ESTIMATED_IMAGE_INPUT_TOKENS = 560;
constructor(config: { apiKey: string }, meteringService: MeteringService) {
this.#meteringService = meteringService;
this.#openai = new openai.OpenAI({
@@ -71,7 +79,7 @@ export class OpenAiImageProvider implements IImageProvider {
}
getDefaultModel(): string {
return 'dall-e-2';
return 'gpt-image-1-mini';
}
async generate({
@@ -80,6 +88,9 @@ export class OpenAiImageProvider implements IImageProvider {
test_mode,
model,
ratio,
input_image,
input_images,
input_image_mime_type,
}: IGenerateParams) {
const selectedModel =
this.models().find((m) => m.id === model) ||
@@ -89,6 +100,12 @@ export class OpenAiImageProvider implements IImageProvider {
return 'https://puter-sample-data.puter.site/image_example.png';
}
// Backwards compat: fold singular `input_image` into `input_images`.
if (input_image && (!input_images || input_images.length === 0)) {
input_images = [input_image];
}
const hasInputImages = (input_images?.length ?? 0) > 0;
if (typeof prompt !== 'string') {
throw new HttpError(400, '`prompt` must be a string', {
legacyCode: 'bad_request',
@@ -146,12 +163,17 @@ export class OpenAiImageProvider implements IImageProvider {
const estimatedPromptTokenCount =
this.#estimatePromptTokenCount(prompt);
const estimatedImageInputTokens = hasInputImages
? (input_images?.length ?? 0) *
OpenAiImageProvider.#ESTIMATED_IMAGE_INPUT_TOKENS
: 0;
const estimatedInputCostInCents = this.#calculateInputCostInCents(
selectedModel,
{
inputTokens: estimatedPromptTokenCount,
inputTokens:
estimatedPromptTokenCount + estimatedImageInputTokens,
inputTextTokens: estimatedPromptTokenCount,
inputImageTokens: 0,
inputImageTokens: estimatedImageInputTokens,
cachedInputTokens: 0,
cachedInputTextTokens: 0,
cachedInputImageTokens: 0,
@@ -174,15 +196,25 @@ export class OpenAiImageProvider implements IImageProvider {
);
}
// Build API parameters based on model
const apiParams = this.#buildApiParams(selectedModel.id, {
user: userIdentifier,
prompt,
size,
quality,
} as Partial<ImageGenerateParamsNonStreaming>);
const result = await this.#openai.images.generate(apiParams);
// With input images we use the edit endpoint (gpt-image only);
// otherwise the standard generate endpoint.
const result = hasInputImages
? await this.#openai.images.edit(
await this.#buildEditParams(
selectedModel.id,
{ user: userIdentifier, prompt, size, quality },
input_images!,
input_image_mime_type,
),
)
: await this.#openai.images.generate(
this.#buildApiParams(selectedModel.id, {
user: userIdentifier,
prompt,
size,
quality,
} as Partial<ImageGenerateParamsNonStreaming>),
);
const usage = this.#extractUsage(result);
const hasInputTokenUsage =
@@ -585,39 +617,81 @@ export class OpenAiImageProvider implements IImageProvider {
}
#buildPriceKey(model: string, quality: string, size: string) {
if (this.#isGptImageModel(model)) {
// GPT image models use format: "quality:size" - default to low if not specified
const qualityLevel = quality || 'low';
return `${qualityLevel}:${size}`;
}
// DALL-E models use format: "hd:size" or just "size"
return (quality === 'hd' ? 'hd:' : '') + size;
// All supported models are gpt-image-*, which price by "quality:size".
const qualityLevel = quality || 'low';
return `${qualityLevel}:${size}`;
}
#buildApiParams(
model: string,
baseParams: Partial<ImageGenerateParamsNonStreaming>,
): ImageGenerateParamsNonStreaming {
const apiParams = {
return {
model,
user: baseParams.user,
prompt: baseParams.prompt,
size: baseParams.size,
// Default to low quality if not specified, consistent with #buildPriceKey.
quality: baseParams.quality || 'low',
} as ImageGenerateParamsNonStreaming;
}
if (this.#isGptImageModel(model)) {
// GPT image models require the model parameter and use quality mapping
apiParams.model = model;
// Default to low quality if not specified, consistent with _buildPriceKey
apiParams.quality = baseParams.quality || 'low';
async #buildEditParams(
model: string,
baseParams: {
user?: string;
prompt?: string;
size?: string;
quality?: string;
},
input_images: string[],
mimeHint?: string,
): Promise<ImageEditParamsNonStreaming> {
const files = await Promise.all(
input_images.map((img) => this.#toUploadable(img, mimeHint)),
);
return {
model,
user: baseParams.user,
prompt: baseParams.prompt,
size: baseParams.size,
quality: baseParams.quality || 'low',
// gpt-image accepts one image or an array of images.
image: files.length === 1 ? files[0] : files,
} as ImageEditParamsNonStreaming;
}
// Accepts a public URL, a `data:<mime>;base64,...` URI, or a raw base64
// string and turns it into an uploadable file for the OpenAI edit endpoint.
// URLs are fetched server-side via the SSRF-guarded secureFetch.
async #toUploadable(img: string, mimeHint?: string) {
let mime = mimeHint ?? 'image/png';
let base64 = img;
if (isHttpUrl(img)) {
const fetched = await fetchImageAsBase64(img);
mime = fetched.mime;
base64 = fetched.base64;
} else {
// dall-e models
apiParams.model = model;
if (baseParams.quality === 'hd') {
apiParams.quality = 'hd';
const dataUri = /^data:([^;]+);base64,(.*)$/s.exec(img);
if (dataUri) {
mime = dataUri[1];
base64 = dataUri[2];
}
}
return apiParams;
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
throw new HttpError(
400,
'Invalid input image (empty or not base64)',
{
legacyCode: 'bad_request',
},
);
}
const ext = mime.split('/')[1]?.split('+')[0] || 'png';
return toFile(buffer, `image.${ext}`, { type: mime });
}
}
@@ -144,46 +144,4 @@ export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [
{ w: 1536, h: 1024 },
],
},
{
puterId: 'openai:openai/dall-e-3',
id: 'dall-e-3',
aliases: ['openai/dall-e-3'],
name: 'DALL-E 3',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1024x1024',
costs: {
'1024x1024': 4,
'1024x1792': 8,
'1792x1024': 8,
'hd:1024x1024': 8,
'hd:1024x1792': 12,
'hd:1792x1024': 12,
},
allowedQualityLevels: ['', 'hd'],
allowedRatios: [
{ w: 1024, h: 1024 },
{ w: 1024, h: 1792 },
{ w: 1792, h: 1024 },
],
},
{
puterId: 'openai:openai/dall-e-2',
id: 'dall-e-2',
aliases: ['openai/dall-e-2'],
name: 'DALL-E 2',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: '1024x1024',
costs: {
'256x256': 1.6,
'512x512': 1.8,
'1024x1024': 2,
},
allowedRatios: [
{ w: 256, h: 256 },
{ w: 512, h: 512 },
{ w: 1024, h: 1024 },
],
},
];
@@ -332,6 +332,55 @@ describe('TogetherImageProvider.generate request shape', () => {
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_base64).toBe('BASE64DATA');
});
it('routes a base64 input_images entry to image_base64', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['BASE64DATA'],
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_base64).toBe('BASE64DATA');
});
it('routes a URL input_images entry to the native image_url field (no fetch)', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['https://example.com/in.png'],
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.image_url).toBe('https://example.com/in.png');
expect(sent.image_base64).toBeUndefined();
});
it('throws 400 when more than one input image is supplied', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.generate({
model: 'togetherai:black-forest-labs/FLUX.1-schnell',
prompt: 'edit it',
input_images: ['BASE64A', 'BASE64B'],
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(generateMock).not.toHaveBeenCalled();
});
});
// ── Output extraction & error mapping ───────────────────────────────
@@ -26,6 +26,7 @@ import type {
IImageProvider,
} from '../../types.js';
import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js';
import { isHttpUrl, resolveSingleInputImage } from '../../inputImage.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 };
@@ -87,6 +88,18 @@ export class TogetherImageProvider implements IImageProvider {
});
}
// Canonical `input_images` → Together's native fields. Together accepts
// a single input image: a URL goes to `image_url`, base64/data-URI to
// `image_base64` (via the existing `input_image` alias).
const singleInput = resolveSingleInputImage(params, 'Together AI');
if (singleInput) {
if (isHttpUrl(singleInput)) {
options.image_url ??= singleInput;
} else {
options.input_image ??= singleInput;
}
}
ratio = ratio || TOGETHER_DEFAULT_RATIO;
const actor = Context.get('actor');
@@ -488,6 +488,16 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [
pricing_unit: 'per-MP',
costs: { '1MP': 6 },
},
{
id: 'togetherai:ideogram/ideogram-4.0',
aliases: ['ideogram/ideogram-4.0', 'ideogram-4.0'],
costs_currency: 'usd-cents',
index_cost_key: '1MP',
name: 'ideogram/ideogram-4.0',
allowedQualityLevels: [''],
pricing_unit: 'per-MP',
costs: { '1MP': 6 },
},
{
id: 'togetherai:openai/gpt-image-1.5',
aliases: ['openai/gpt-image-1.5', 'gpt-image-1.5'],
@@ -49,8 +49,9 @@ import { XAIImageProvider } from './XAIImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { generateMock, openAICtor } = vi.hoisted(() => ({
const { generateMock, postMock, openAICtor } = vi.hoisted(() => ({
generateMock: vi.fn(),
postMock: vi.fn(),
openAICtor: vi.fn(),
}));
@@ -61,6 +62,8 @@ vi.mock('openai', () => {
) {
openAICtor(opts);
this.images = { generate: generateMock };
// Low-level post() is how the provider reaches xAI's JSON edit endpoint.
this.post = postMock;
// Some sibling providers boot through the same SDK module.
this.chat = { completions: { create: vi.fn() } };
});
@@ -71,7 +74,9 @@ vi.mock('openai', () => {
let server: PuterServer;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
let batchIncrementUsagesSpy: MockInstance<
MeteringService['batchIncrementUsages']
>;
beforeAll(async () => {
server = await setupTestServer();
@@ -86,9 +91,13 @@ const makeProvider = () =>
beforeEach(() => {
generateMock.mockReset();
postMock.mockReset();
openAICtor.mockReset();
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
batchIncrementUsagesSpy = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
});
afterEach(() => {
@@ -121,15 +130,22 @@ describe('XAIImageProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('XAIImageProvider model catalog', () => {
it('returns grok-2-image as the default', () => {
it('returns grok-imagine-image as the default', () => {
const provider = makeProvider();
expect(provider.getDefaultModel()).toBe('grok-2-image');
expect(provider.getDefaultModel()).toBe('grok-imagine-image');
});
it('exposes the static XAI_IMAGE_GENERATION_MODELS list verbatim', () => {
const provider = makeProvider();
expect(provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
});
it('no longer exposes the deprecated grok-2-image model', () => {
const provider = makeProvider();
expect(provider.models().some((m) => m.id === 'grok-2-image')).toBe(
false,
);
});
});
// ── test_mode bypass ────────────────────────────────────────────────
@@ -149,7 +165,7 @@ describe('XAIImageProvider.generate test_mode', () => {
);
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(generateMock).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -188,7 +204,7 @@ describe('XAIImageProvider.generate credit gate', () => {
).rejects.toMatchObject({ statusCode: 402 });
expect(generateMock).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -208,7 +224,7 @@ describe('XAIImageProvider.generate model resolution', () => {
}),
);
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
});
it('resolves an alias to its canonical id', async () => {
@@ -217,20 +233,20 @@ describe('XAIImageProvider.generate model resolution', () => {
await withTestActor(() =>
provider.generate({
// grok-image is an alias of grok-2-image.
// grok-image is an alias of grok-imagine-image.
model: 'grok-image',
prompt: 'hi',
}),
);
expect(generateMock.mock.calls[0]![0].model).toBe('grok-2-image');
expect(generateMock.mock.calls[0]![0].model).toBe('grok-imagine-image');
});
});
// ── Successful generation ───────────────────────────────────────────
describe('XAIImageProvider.generate success path', () => {
it('returns the URL from response.data[0].url and meters one image', async () => {
it('returns the URL from response.data[0].url and meters one image at the 1k output rate', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://x.ai/img/abc' }],
@@ -238,22 +254,48 @@ describe('XAIImageProvider.generate success path', () => {
const result = await withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
);
expect(result).toBe('https://x.ai/img/abc');
// No input images → generate endpoint, not the edit endpoint.
expect(postMock).not.toHaveBeenCalled();
// One increment, at the model's per-image rate (7 cents → 7,000,000 ucents).
const grok = XAI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'grok-2-image',
(m) => m.id === 'grok-imagine-image',
)!;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('xai:grok-2-image:output');
expect(count).toBe(1);
expect(cost).toBe(grok.costs.output * 1_000_000);
expect(batchIncrementUsagesSpy).toHaveBeenCalledTimes(1);
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
expect(entries).toHaveLength(1);
const out = (
entries as Array<{ usageType: string; costOverride: number }>
)[0];
expect(out.usageType).toBe('xai:grok-imagine-image:output:1k');
expect(out.costOverride).toBe(grok.costs['output:1k'] * 1_000_000);
});
it('uses the 2k output rate when quality is "2k"', async () => {
const provider = makeProvider();
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://x.ai/img/2k' }],
});
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image-quality',
prompt: 'hi',
quality: '2k',
}),
);
const sent = generateMock.mock.calls[0]![0];
expect(sent.resolution).toBe('2k');
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
expect(
(entries as Array<{ usageType: string }>)[0].usageType,
).toBe('xai:grok-imagine-image-quality:output:2k');
});
it('falls back to a base64 data URL when response carries b64_json', async () => {
@@ -264,7 +306,7 @@ describe('XAIImageProvider.generate success path', () => {
const result = await withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
);
@@ -279,13 +321,117 @@ describe('XAIImageProvider.generate success path', () => {
await expect(
withTestActor(() =>
provider.generate({
model: 'grok-2-image',
model: 'grok-imagine-image',
prompt: 'a small red dot',
}),
),
).rejects.toThrow(/Failed to extract image URL/);
// Failure path must NOT meter usage.
expect(incrementUsageSpy).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
// ── Image-to-image editing (input_images) ───────────────────────────
describe('XAIImageProvider.generate input_images (edit endpoint)', () => {
const PNG = 'data:image/png;base64,iVBORw0KGgo=';
const editResponse = { data: [{ url: 'https://x.ai/img/edited' }] };
it('routes input_images to POST /v1/images/edits (not generate) with a single image object', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
const result = await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_images: [PNG],
}),
);
expect(result).toBe('https://x.ai/img/edited');
expect(generateMock).not.toHaveBeenCalled();
expect(postMock).toHaveBeenCalledTimes(1);
const [path, opts] = postMock.mock.calls[0]!;
expect(path).toBe('/images/edits');
const body = (opts as { body: Record<string, unknown> }).body;
expect(body.model).toBe('grok-imagine-image');
// Single image → object, not an array.
expect(body.image).toEqual({ type: 'image_url', url: PNG });
});
it('sends an array of image objects for multi-image edits and caps at 3', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'merge them',
input_images: [PNG, PNG, PNG, PNG], // 4 → capped to 3
}),
);
const body = (
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
).body;
expect(Array.isArray(body.image)).toBe(true);
expect(body.image).toHaveLength(3);
});
it('meters output + media_input per input image on edits', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_images: [PNG, PNG],
}),
);
const grok = XAI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'grok-imagine-image',
)!;
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
const types = (entries as Array<{ usageType: string }>).map(
(e) => e.usageType,
);
expect(types).toEqual(
expect.arrayContaining([
'xai:grok-imagine-image:output:1k',
'xai:grok-imagine-image:media_input',
]),
);
const media = (
entries as Array<{
usageType: string;
usageAmount: number;
costOverride: number;
}>
).find((e) => e.usageType.endsWith(':media_input'))!;
expect(media.usageAmount).toBe(2);
expect(media.costOverride).toBe(grok.costs.media_input * 2 * 1_000_000);
});
it('folds singular input_image into the edit path', async () => {
const provider = makeProvider();
postMock.mockResolvedValueOnce(editResponse);
await withTestActor(() =>
provider.generate({
model: 'grok-imagine-image',
prompt: 'add a hat',
input_image: PNG,
}),
);
expect(postMock).toHaveBeenCalledTimes(1);
const body = (
postMock.mock.calls[0]![1] as { body: Record<string, unknown> }
).body;
expect(body.image).toEqual({ type: 'image_url', url: PNG });
});
});
@@ -28,8 +28,13 @@ import type {
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
import { HttpError } from '../../../../core/http/HttpError.js';
const DEFAULT_MODEL = 'grok-2-image';
const PRICE_KEY = 'output';
const DEFAULT_MODEL = 'grok-imagine-image';
// xAI's Grok Imagine edit endpoint accepts up to 3 source images per request.
const MAX_INPUT_IMAGES = 3;
interface XaiImageResponse {
data?: Array<{ url?: string; b64_json?: string }>;
}
export class XAIImageProvider implements IImageProvider {
#client: OpenAI;
@@ -56,8 +61,9 @@ export class XAIImageProvider implements IImageProvider {
}
async generate(params: IGenerateParams): Promise<string> {
const { prompt, test_mode } = params;
const { model } = params;
const { prompt, test_mode, model, ratio, quality } = params;
let { input_images } = params;
const { input_image, input_image_mime_type } = params;
const selectedModel = this.#getModel(model);
@@ -71,15 +77,33 @@ export class XAIImageProvider implements IImageProvider {
});
}
// Backwards compat: fold singular `input_image` into `input_images`.
if (input_image && (!input_images || input_images.length === 0)) {
input_images = [input_image];
}
// xAI caps edits at 3 source images.
if (input_images && input_images.length > MAX_INPUT_IMAGES) {
input_images = input_images.slice(0, MAX_INPUT_IMAGES);
}
const inputImageCount = input_images?.length ?? 0;
const hasInputImages = inputImageCount > 0;
// xAI uses a `resolution` tier ('1k'/'2k') rather than a pixel size.
const resolution = this.#normalizeResolution(quality);
const aspectRatio = this.#aspectRatio(ratio);
const actor = Context.get('actor');
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const priceInCents = selectedModel.costs[PRICE_KEY];
const costInMicroCents = priceInCents * 1_000_000;
const outputPriceInCents = selectedModel.costs[`output:${resolution}`];
const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0;
const estimatedCostInCents =
outputPriceInCents +
(hasInputImages ? mediaInputPriceInCents * inputImageCount : 0);
const usageAllowed = await this.#meteringService.hasEnoughCredits(
actor,
costInMicroCents,
estimatedCostInCents * 1_000_000,
);
if (!usageAllowed) {
@@ -90,15 +114,27 @@ export class XAIImageProvider implements IImageProvider {
);
}
const response = await this.#client.images.generate({
model: selectedModel.id,
prompt,
user: userIdentifier,
});
const response = hasInputImages
? await this.#edit(
selectedModel.id,
prompt,
input_images!,
input_image_mime_type,
resolution,
aspectRatio,
)
: ((await this.#client.images.generate({
model: selectedModel.id,
prompt,
user: userIdentifier,
// xAI-specific params not in the OpenAI type; passed through.
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
resolution,
} as Parameters<
OpenAI['images']['generate']
>[0])) as XaiImageResponse);
const first = response.data?.[0] as
| { url?: string; b64_json?: string }
| undefined;
const first = response.data?.[0];
const url =
first?.url ||
(first?.b64_json
@@ -109,16 +145,75 @@ export class XAIImageProvider implements IImageProvider {
throw new Error('Failed to extract image URL from xAI response');
}
this.#meteringService.incrementUsage(
actor,
`xai:${selectedModel.id}:${PRICE_KEY}`,
1,
costInMicroCents,
);
const usageEntries = [
{
usageType: `xai:${selectedModel.id}:output:${resolution}`,
usageAmount: 1,
costOverride: outputPriceInCents * 1_000_000,
},
];
if (hasInputImages && mediaInputPriceInCents > 0) {
usageEntries.push({
usageType: `xai:${selectedModel.id}:media_input`,
usageAmount: inputImageCount,
costOverride:
mediaInputPriceInCents * inputImageCount * 1_000_000,
});
}
this.#meteringService.batchIncrementUsages(actor, usageEntries);
return url;
}
// Edits go to POST /v1/images/edits as application/json (the OpenAI SDK's
// images.edit() can't be used — it sends multipart/form-data, which xAI
// rejects). We reuse the SDK client's auth + baseURL via its low-level
// post(). Input images are passed as `{ type: 'image_url', url }` objects;
// a single object for one image, an array for multiple.
async #edit(
modelId: string,
prompt: string,
inputImages: string[],
mimeHint: string | undefined,
resolution: string,
aspectRatio: string | undefined,
): Promise<XaiImageResponse> {
const refs = inputImages.map((img) => this.#toImageRef(img, mimeHint));
const body: Record<string, unknown> = {
model: modelId,
prompt,
image: refs.length === 1 ? refs[0] : refs,
resolution,
};
if (aspectRatio) body.aspect_ratio = aspectRatio;
return (await this.#client.post('/images/edits', {
body,
})) as XaiImageResponse;
}
// xAI accepts a public URL or a base64 data URI for input images.
#toImageRef(img: string, mimeHint?: string) {
const url =
img.startsWith('http://') ||
img.startsWith('https://') ||
img.startsWith('data:')
? img
: `data:${mimeHint ?? 'image/png'};base64,${img}`;
return { type: 'image_url', url };
}
#normalizeResolution(quality?: string): '1k' | '2k' {
return (quality ?? '').toLowerCase() === '2k' ? '2k' : '1k';
}
#aspectRatio(ratio?: { w: number; h: number }): string | undefined {
if (!ratio || !ratio.w || !ratio.h) return undefined;
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b);
const d = gcd(ratio.w, ratio.h) || 1;
return `${ratio.w / d}:${ratio.h / d}`;
}
#getModel(model?: string) {
const models = this.models();
const found = models.find(
@@ -19,17 +19,43 @@
import type { IImageModel } from '../../types.js';
// Costs are in usd-cents (1 = $0.01). xAI's "Grok Imagine" image API bills a
// per-image output rate by resolution tier (1k/2k) plus, for edits, a
// per-input-image "media input" rate. Rates per the xAI Imagine pricing table:
// https://docs.x.ai/developers/pricing
// grok-imagine-image media $0.002 | 1k $0.02 | 2k $0.02
// grok-imagine-image-quality media $0.01 | 1k $0.05 | 2k $0.07
export const XAI_IMAGE_GENERATION_MODELS: IImageModel[] = [
{
puterId: 'x-ai:x-ai/grok-2-image',
id: 'grok-2-image',
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-2-image'],
name: 'Grok 2 Image',
puterId: 'x-ai:x-ai/grok-imagine-image',
id: 'grok-imagine-image',
aliases: ['grok-image', 'x-ai/grok-image', 'x-ai/grok-imagine-image'],
name: 'Grok Imagine Image',
version: '1.0',
costs_currency: 'usd-cents',
index_cost_key: 'output',
pricing_unit: 'per-image',
index_cost_key: 'output:1k',
costs: {
output: 7, // $0.07 per image
'output:1k': 2, // $0.02 per image
'output:2k': 2, // $0.02 per image
media_input: 0.2, // $0.002 per input image (edits)
},
allowedQualityLevels: ['1k', '2k'],
},
{
puterId: 'x-ai:x-ai/grok-imagine-image-quality',
id: 'grok-imagine-image-quality',
aliases: ['x-ai/grok-imagine-image-quality'],
name: 'Grok Imagine Image (Quality)',
version: '1.0',
costs_currency: 'usd-cents',
pricing_unit: 'per-image',
index_cost_key: 'output:1k',
costs: {
'output:1k': 5, // $0.05 per image
'output:2k': 7, // $0.07 per image
media_input: 1, // $0.01 per input image (edits)
},
allowedQualityLevels: ['1k', '2k'],
},
];
@@ -292,6 +292,22 @@ describe('loadFileInput FS path', () => {
expect(result.fsEntry?.path).toBe(path);
});
it('returns empty bytes for an empty file with no backing S3 object', async () => {
// Files created via touch have size 0 and a null bucket — no S3
// object exists. loadFileInput must return empty content rather than
// throwing NoSuchKey from getObjectStream.
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/empty.txt`;
const entry = await withActor(actor, () =>
server.services.fs.touch(userId, { path }),
);
const result = await callLoadFileInput(actor, path);
expect(result.buffer.byteLength).toBe(0);
expect(result.fsEntry?.uuid).toBe(entry.uuid);
});
it('also accepts a `{ uuid }` object reference', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
+18
View File
@@ -22,6 +22,7 @@ import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { FSService } from '../../services/fs/FSService.js';
import { expandTildePath, resolveNode } from '../../services/fs/resolveNode.js';
import { hasNoBackingS3Object } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../stores/fs/S3ObjectStore.js';
import { mimeFromName } from '../../util/fileSigning.js';
@@ -175,6 +176,23 @@ export async function loadFileInput(
// `path`/`uid`/`uuid` (e.g. AI chat `puter_path` content parts) could
// exfiltrate any user's file. Must run before the S3 read below.
await fsService.checkFSAccess(entry, actor, 'read');
// Empty files (created via `touch`) have no backing S3 object —
// getObjectStream would throw NoSuchKey, so return empty content.
if (hasNoBackingS3Object(entry)) {
return {
buffer: Buffer.alloc(0),
filename: entry.name,
mimeType: mimeFromName(entry.name) ?? 'application/octet-stream',
fsEntry: {
uuid: entry.uuid,
path: entry.path,
bucket: entry.bucket,
bucketRegion: entry.bucketRegion,
size: entry.size,
sqlId: entry.id,
},
};
}
const objectKey = entry.uuid;
const { body, contentType, contentLength } =
await stores.s3Object.getObjectStream(
+9 -1
View File
@@ -165,7 +165,8 @@ export class WorkerDriver extends PuterDriver {
{ legacyCode: 'forbidden' },
);
}
appOwnerId = actor.app?.id;
const ownerApp = await this.stores.app.getByUid(appId);
appOwnerId = ownerApp?.id ?? actor.app?.id;
authorization = await this.services.auth.createWorkerAppToken(
actor,
appId,
@@ -248,6 +249,13 @@ export class WorkerDriver extends PuterDriver {
});
}
if (appId) {
await this.services.fs.mkdir(actor.user.id!, {
path: `/${actor.user.username}/AppData/${appId}`,
createMissingParents: true,
});
}
// Deploy to Cloudflare
const cfResult = await this.#cfDeploy(
workerName,
+49 -9
View File
@@ -25,7 +25,9 @@ import type { Application, RequestHandler } from 'express';
import helmet from 'helmet';
import uaParser from 'ua-parser-js';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs } from 'node:util';
import http from 'node:http';
import { puterClients } from './clients';
import { puterControllers } from './controllers';
@@ -481,7 +483,6 @@ export class PuterServer {
createAuthProbe({
authService,
cookieName: this.#config.cookie_name,
kvStore: this.stores.kv,
}),
);
}
@@ -729,11 +730,13 @@ export class PuterServer {
if (status < 500) return;
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
}
const signature = isHttp
? err.legacyCode || err.code || err.message
: err instanceof Error
? err.message
: String(err);
const signature = !isHttp
? err instanceof Error
? err.message
: String(err)
: status >= 500
? `${err.legacyCode || err.code || 'http'}:${err.message}`
: err.legacyCode || err.code || err.message;
const routePath =
(req as unknown as { route?: { path?: string } }).route
?.path ?? req.path;
@@ -1144,9 +1147,46 @@ export class PuterServer {
await this.#fireOnServerStart();
console.log('PuterServer has fully booted.');
// Auto-launch the browser on dev boot (matches v1 WebServerService).
// Opt out via `no_browser_launch: true` in config.
if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
// CLI: `--server` (optionally `--puter-backend=<gui-origin>`)
// runs the AuthMe flow against a remote Puter (default
// puter.com), then opens the local GUI already logged in and
// pointed at that backend. Restores the v1 WebServerService
// `--server` behavior; works in any env. When set, it takes
// over browser launch so we don't also open a plain tab.
const { values: cliArgs } = parseArgs({
args: process.argv.slice(2),
options: {
server: { type: 'boolean' },
'puter-backend': { type: 'string' },
},
strict: false,
});
if (cliArgs.server) {
try {
// tools/auth_gui.js is not compiled into dist/, so
// resolve it from the package root (cwd, per the
// `start` script) rather than relative to this module.
const authGuiUrl = pathToFileURL(
path.resolve(process.cwd(), 'tools/auth_gui.js'),
).href;
const authGui = (await import(authGuiUrl)).default;
await authGui(
cliArgs['puter-backend'] as string | undefined,
);
} catch (e) {
console.log(
'[server] could not start AuthMe browser flow:',
(e as Error).message,
);
}
} else if (
this.#config.env === 'dev' &&
!cfg.no_browser_launch
) {
// Auto-launch the browser on dev boot (matches v1
// WebServerService). Opt out via `no_browser_launch: true`.
try {
const openModule = await import('open');
await openModule.default(liveUrl);
@@ -0,0 +1,174 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { AppOriginBlocklistService } from './AppOriginBlocklistService.js';
type Row = {
domain: string;
include_subdomains: number;
reason?: string | null;
};
const makeService = (
rows: Row[],
read?: () => Promise<unknown>,
): { service: AppOriginBlocklistService; reads: { count: number } } => {
const reads = { count: 0 };
const db = {
read:
read ??
(async () => {
reads.count++;
return rows;
}),
};
const service = new AppOriginBlocklistService(
{} as never,
{ db } as never,
{} as never,
{} as never,
);
return { service, reads };
};
describe('AppOriginBlocklistService', () => {
describe('isHostBlocked — exact entries', () => {
it('matches the exact host only', async () => {
const { service } = makeService([
{ domain: 'some.evil.com', include_subdomains: 0 },
]);
expect(await service.isHostBlocked('some.evil.com')).toEqual({
blocked: true,
reason: undefined,
});
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
false,
);
expect(
(await service.isHostBlocked('x.some.evil.com')).blocked,
).toBe(false);
});
});
describe('isHostBlocked — include_subdomains entries', () => {
it('matches the apex and any subdomain, but not lookalikes', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1, reason: 'abuse' },
]);
expect(await service.isHostBlocked('evil.com')).toEqual({
blocked: true,
reason: 'abuse',
});
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
true,
);
expect((await service.isHostBlocked('a.b.evil.com')).blocked).toBe(
true,
);
// Suffix-but-not-subdomain must NOT match.
expect((await service.isHostBlocked('notevil.com')).blocked).toBe(
false,
);
expect((await service.isHostBlocked('evil.com.org')).blocked).toBe(
false,
);
});
});
describe('normalization', () => {
it('lowercases, strips port, and ignores empty input', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1 },
]);
expect((await service.isHostBlocked('A.EVIL.COM')).blocked).toBe(
true,
);
expect(
(await service.isHostBlocked('a.evil.com:8080')).blocked,
).toBe(true);
expect((await service.isHostBlocked('')).blocked).toBe(false);
});
it('normalizes stored entries too (uppercase/leading dot)', async () => {
const { service } = makeService([
{ domain: '.Evil.COM', include_subdomains: 1 },
]);
expect((await service.isHostBlocked('a.evil.com')).blocked).toBe(
true,
);
});
});
describe('isOriginBlocked', () => {
it('extracts the host from a full URL', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 1 },
]);
expect(
(await service.isOriginBlocked('https://app.evil.com/path?q=1'))
.blocked,
).toBe(true);
expect(
(await service.isOriginBlocked('https://good.com/')).blocked,
).toBe(false);
});
it('accepts a scheme-less origin', async () => {
const { service } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
expect((await service.isOriginBlocked('evil.com')).blocked).toBe(
true,
);
});
});
describe('caching', () => {
it('reuses the cached snapshot within the TTL', async () => {
const { service, reads } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
await service.isHostBlocked('evil.com');
await service.isHostBlocked('evil.com');
expect(reads.count).toBe(1);
});
it('reloads after invalidate()', async () => {
const { service, reads } = makeService([
{ domain: 'evil.com', include_subdomains: 0 },
]);
await service.isHostBlocked('evil.com');
service.invalidate();
await service.isHostBlocked('evil.com');
expect(reads.count).toBe(2);
});
});
describe('resilience', () => {
it('fails open (not blocked) when the DB read throws', async () => {
const { service } = makeService([], async () => {
throw new Error('no such table: blocked_app_origins');
});
expect((await service.isHostBlocked('evil.com')).blocked).toBe(
false,
);
});
});
});
@@ -0,0 +1,172 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PuterService } from '../types.js';
export interface BlockMatch {
blocked: boolean;
reason?: string;
}
interface BlocklistEntry {
domain: string;
includeSubdomains: boolean;
reason: string | null;
}
const NOT_BLOCKED: BlockMatch = { blocked: false };
/**
* In-memory, TTL-cached view of the `blocked_app_origins` table.
*
* Admins manage the table (the admin extension writes it directly, mirroring
* how it writes the `user` table for suspend). This service answers the hot
* "is this app/origin blocked?" question from a cached snapshot so the
* per-request app-token validation path never hits the DB.
*
* Consistency: the cache refreshes lazily after {@link CACHE_TTL_MS}. Because
* each worker process holds its own cache, a freshly-added block can take up
* to one TTL to take effect across the fleet — acceptable for an
* admin-initiated block.
*/
export class AppOriginBlocklistService extends PuterService {
private static readonly CACHE_TTL_MS = 30_000;
#entries: BlocklistEntry[] = [];
#loadedAt = 0;
#inflight: Promise<void> | null = null;
/**
* Decide whether a bare host (already without scheme/path) is blocked.
* Exact entries match the host verbatim; `include_subdomains` entries
* also match any subdomain of `domain`.
*/
async isHostBlocked(host: string): Promise<BlockMatch> {
const normalized = normalizeHost(host);
if (!normalized) return NOT_BLOCKED;
await this.#ensureFresh();
for (const entry of this.#entries) {
const matches = entry.includeSubdomains
? normalized === entry.domain ||
normalized.endsWith(`.${entry.domain}`)
: normalized === entry.domain;
if (matches) {
return {
blocked: true,
reason: entry.reason ?? undefined,
};
}
}
return NOT_BLOCKED;
}
/**
* Decide whether an origin/URL is blocked by extracting its host. Accepts
* full URLs (`https://app.example.com/path`) and bare hosts alike.
*/
async isOriginBlocked(origin: string): Promise<BlockMatch> {
return this.isHostBlocked(hostFromOrigin(origin));
}
/** Drop the cached snapshot so the next query reloads from the DB. */
invalidate(): void {
this.#loadedAt = 0;
}
async #ensureFresh(): Promise<void> {
const age = Date.now() - this.#loadedAt;
if (
this.#loadedAt !== 0 &&
age < AppOriginBlocklistService.CACHE_TTL_MS
) {
return;
}
// Single-flight: concurrent callers share one reload.
if (!this.#inflight) {
this.#inflight = this.#reload().finally(() => {
this.#inflight = null;
});
}
await this.#inflight;
}
async #reload(): Promise<void> {
try {
const rows = (await this.clients.db.read(
'SELECT `domain`, `include_subdomains`, `reason` FROM `blocked_app_origins`',
)) as Array<Record<string, unknown>>;
this.#entries = rows
.map((row) => {
const domain = normalizeHost(String(row.domain ?? ''));
if (!domain) return null;
return {
domain,
includeSubdomains: Boolean(
Number(row.include_subdomains ?? 0),
),
reason: row.reason == null ? null : String(row.reason),
} satisfies BlocklistEntry;
})
.filter((e): e is BlocklistEntry => e !== null);
this.#loadedAt = Date.now();
} catch (e) {
// Never let a transient DB error turn into a request-blocking
// throw on the auth hot path. Keep serving the previous snapshot;
// a missing table (fresh dev DB pre-migration) yields an empty
// blocklist, which is the safe-open default.
console.warn(
'[app-origin-blocklist] reload failed:',
(e as Error)?.message ?? e,
);
if (this.#loadedAt === 0) {
this.#entries = [];
this.#loadedAt = Date.now();
}
}
}
}
/** Lowercase, trim, drop a leading dot and any port. Returns '' when unusable. */
const normalizeHost = (host: string): string => {
let h = (host ?? '').trim().toLowerCase();
if (!h) return '';
h = h.replace(/^\./, '');
// Strip a trailing :port (IPv6 literals are not app origins, so the
// simple split is safe here).
const colon = h.indexOf(':');
if (colon !== -1) h = h.slice(0, colon);
return h;
};
/** Extract the host from a full URL, falling back to treating input as a host. */
const hostFromOrigin = (origin: string): string => {
const raw = (origin ?? '').trim();
if (!raw) return '';
try {
return new URL(raw).hostname;
} catch {
// Not a parseable URL — maybe a scheme-less origin or bare host.
try {
return new URL(`https://${raw}`).hostname;
} catch {
return raw;
}
}
};
@@ -25,21 +25,25 @@ import { PuterService } from '../types.js';
* Resolved at call time against the apps table.
*/
const RECOMMENDED_APP_NAMES = [
'app-center',
'builder',
'editor',
'camera',
'recorder',
'app-center',
'dev-center',
'butler',
'editor',
'code',
'camera',
'music-player',
'recorder',
'contacts',
'browser',
'memos',
'audio-editor',
'ai-image-project',
'word-processor',
'spreadsheet',
'presentation',
'pdf-editor',
'vault',
'basketball-tap',
'blockup',
'pretty-tiles',
@@ -72,5 +76,8 @@ function toAppSummary(
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: app.index_url,
// An app with no owner isn't owned by a Puter user — it's an
// "external" (origin-bootstrapped) app.
external: app.owner_user_id == null || app.owner_user_id === '',
};
}
@@ -1348,6 +1348,85 @@ describe('AuthService (integration)', () => {
});
});
describe('app origin blocklist enforcement', () => {
// The blocklist service caches with a TTL, so seed the row then
// invalidate the in-memory snapshot to force a reload for the test.
const blockOrigin = async (
domain: string,
includeSubdomains = false,
) => {
await server.clients.db.write(
'INSERT INTO `blocked_app_origins` (`domain`, `include_subdomains`) VALUES (?, ?)',
[domain, includeSubdomains ? 1 : 0],
);
(
server.services.appOriginBlocklist as {
invalidate: () => void;
}
).invalidate();
};
it('appUidFromOrigin throws 403 app_blocked for a blocked exact host', async () => {
const host = `blocked-${uuidv4()}.example.com`;
await blockOrigin(host);
await expect(
authService.appUidFromOrigin(`https://${host}/`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_blocked',
});
});
it('appUidFromOrigin throws for a subdomain of an include_subdomains entry', async () => {
const apex = `evil-${uuidv4()}.example.com`;
await blockOrigin(apex, true);
await expect(
authService.appUidFromOrigin(`https://app.${apex}/`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'app_blocked',
});
});
it('appUidFromOrigin still resolves an unrelated origin', async () => {
const uid = await authService.appUidFromOrigin(
`https://fine-${uuidv4()}.example.com/`,
);
expect(uid).toMatch(/^app-/);
});
it('rejects an already-issued app token once its origin is blocked', async () => {
const user = await makeUser();
const host = `late-block-${uuidv4()}.example.com`;
const appUid = `app-${uuidv4()}`;
// App row carries the to-be-blocked host as its index_url.
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[appUid, `n-${appUid}`, `t-${appUid}`, `https://${host}/`, 1],
);
const appToken = await authService.getUserAppToken(
{
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
},
} as Actor,
appUid,
);
// Before blocking the token authenticates normally.
const ok = await authService.authenticate(appToken);
expect(ok.actor?.app?.uid).toBe(appUid);
// After blocking the same token is rejected with the blocked signal.
await blockOrigin(host);
const blocked = await authService.authenticate(appToken);
expect(blocked.actor).toBeUndefined();
expect(blocked.blocked).toBeTruthy();
});
});
describe('getUserAppToken', () => {
it('throws 403 when actor has no user', async () => {
await expect(
+35
View File
@@ -50,6 +50,13 @@ export interface AuthResult {
actor?: Actor;
reauth?: { reason: ReauthReason; auth_id?: string };
invalid?: true;
/**
* The token authenticated, but its app is on the origin blocklist. The
* auth probe surfaces this as `req.appBlocked`; gates translate it to a
* 403 `app_blocked`. Distinct from `invalid` so the client sees a clear
* "app blocked" error rather than a generic auth failure.
*/
blocked?: { reason?: string };
}
/**
@@ -891,6 +898,19 @@ export class AuthService extends PuterService {
const event = { origin: aliased };
await this.clients.event?.emitAndWait('app.from-origin', event, {});
// Blocked origins can't acquire an app token (or have one minted /
// checked / granted), so the app loses every path to Puter resources.
const block = await this.services.appOriginBlocklist.isOriginBlocked(
event.origin,
);
if (block.blocked) {
throw new HttpError(
403,
'This app is not allowed to access Puter resources',
{ legacyCode: 'app_blocked' },
);
}
const canonicalUid = await this.#findCanonicalAppUidForOrigin(
event.origin,
);
@@ -1784,6 +1804,21 @@ export class AuthService extends PuterService {
const app = await this.stores.app.getByUid(decoded.app_uid);
if (!app) return { invalid: true };
// Reject already-issued app tokens whose app origin is now blocked, so
// a block takes effect immediately rather than waiting for token
// expiry. The app's `index_url` host is the same origin checked at
// token acquisition.
const indexUrl = (app as { index_url?: unknown }).index_url;
if (typeof indexUrl === 'string' && indexUrl) {
const block =
await this.services.appOriginBlocklist.isOriginBlocked(
indexUrl,
);
if (block.blocked) {
return { blocked: { reason: block.reason } };
}
}
let rawRow: SessionRow | null = null;
if (decoded.session_uid) {
rawRow = (await this.stores.session.getByUuidAny(
@@ -211,4 +211,25 @@ describe('OIDCService.createUserFromOIDC', () => {
expect(result.success).toBe(false);
expect(result.error).toMatch(/verify/i);
});
it('refuses to create a fresh account when registration is disabled', async () => {
const oidcConfig = server.services.oidc.config as {
disable_user_signup?: boolean;
};
const prev = oidcConfig.disable_user_signup;
oidcConfig.disable_user_signup = true;
try {
const result = await runWithContext({ req }, () =>
oidc().createUserFromOIDC('microsoft', {
sub: 'disabled-sub',
email: '[email protected]',
email_verified: true,
}),
);
expect(result.success).toBe(false);
expect(result.error).toMatch(/disabled/i);
} finally {
oidcConfig.disable_user_signup = prev;
}
});
});
+7
View File
@@ -443,6 +443,13 @@ export class OIDCService extends PuterService {
};
}
if (this.config.disable_user_signup) {
return {
success: false,
error: 'User registration is disabled.',
};
}
// Generate a unique username
let username: string;
let attempts = 0;
@@ -188,6 +188,28 @@ describe('TokenService.sign', () => {
expect(payload.exp).toBeUndefined();
});
it('signs from config even before onServerStart runs (boot-window race)', () => {
// The http socket starts accepting connections before onServerStart
// finishes, so a login can arrive while the service is still booting.
// Secrets are read straight from config (not copied in onServerStart),
// so signing must work without onServerStart having run — otherwise
// jwt.sign would get an empty secret and throw `secretOrPrivateKey
// must have a value`, surfacing as a 500.
const config = {
jwt_secret: V1_SECRET,
jwt_secret_v2: V2_SECRET,
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[3],
];
const svc = new TokenService(config, clients, stores, services);
// Note: onServerStart() intentionally NOT called.
const token = svc.sign('auth', { type: 'session' });
expect(() => jwt.verify(token, V2_SECRET)).not.toThrow();
});
it('caller cannot override the `kid` routing discriminant', () => {
const svc = createTokenService();
const token = svc.sign(
+21 -9
View File
@@ -190,9 +190,22 @@ const SHIPPED_PLACEHOLDER_SECRETS = new Set([
]);
export class TokenService extends PuterService {
#secretV2: string = '';
#secretLegacy: string = '';
#allowV1Tokens = true;
// Secrets are read straight from `this.config`, which is populated at
// construction (before any onServerStart runs). They are deliberately NOT
// copied into fields during onServerStart: the http socket starts
// accepting connections before onServerStart finishes, so a copied field
// would still be empty for any request that lands in the boot window —
// producing a cryptic `secretOrPrivateKey must have a value` 500 on login.
// Reading config directly closes that window entirely.
get #secretV2(): string {
return this.config.jwt_secret_v2 ?? '';
}
get #secretLegacy(): string {
return this.config.jwt_secret ?? '';
}
get #allowV1Tokens(): boolean {
return this.config.allow_v1_tokens !== false;
}
override onServerStart(): void {
const secretV2 = this.config.jwt_secret_v2;
@@ -227,12 +240,11 @@ export class TokenService extends PuterService {
}
}
}
this.#secretV2 = secretV2;
// Legacy secret is optional in fresh installs (no v1 tokens to verify),
// but every existing deployment carries one. Don't fail boot if it's
// missing — instead, refuse to verify v1 tokens later.
this.#secretLegacy = this.config.jwt_secret ?? '';
this.#allowV1Tokens = this.config.allow_v1_tokens !== false;
// Note: secrets are exposed via getters over `this.config` (see above),
// not copied into fields here. onServerStart only validates them at
// boot — fail fast on a missing v2 secret or a shipped placeholder.
// The legacy `jwt_secret` stays optional (fresh installs have no v1
// tokens to verify); a missing one is handled at verify time.
}
/**
+66 -9
View File
@@ -31,6 +31,7 @@ import {
FSEntry,
FSEntryCreateInput,
FSEntryWriteInput,
hasNoBackingS3Object,
PendingUploadCreateInput,
PendingUploadSession,
} from '../../stores/fs/FSEntry.js';
@@ -2806,6 +2807,24 @@ export class FSService extends PuterService {
{ legacyCode: 'shortcut_target_not_found' },
);
}
// Empty files (created via `touch`/`createNonFileEntry` with kind
// 'empty-file') have no backing S3 object — `bucket` is null. Reading
// one would throw NoSuchKey and, worse, trip #handleGhostFile, which
// deletes the entry as if it were an orphan. Return an empty stream
// instead. (A real file whose object is genuinely missing keeps a
// non-null bucket, so it still falls through to the ghost path below.)
if (hasNoBackingS3Object(entry)) {
return {
body: Readable.from([]),
contentLength: 0,
contentType: null,
contentRange: null,
etag: null,
lastModified: entry.modified
? new Date(entry.modified * 1000)
: null,
};
}
const objectKey = entry.uuid;
try {
return await this.stores.s3Object.getObjectStream(
@@ -3646,20 +3665,58 @@ export class FSService extends PuterService {
});
}
// Empty files (created via `touch`/`createNonFileEntry` with kind
// 'empty-file') have no backing S3 object — `bucket` is null and there
// is nothing to CopyObject. Issuing one would throw NoSuchKey, so clone
// the source as another empty-file entry instead of touching S3.
if (hasNoBackingS3Object(source)) {
return this.stores.fsEntry.createNonFileEntry({
userId,
parent: destinationParent,
name: newName,
kind: 'empty-file',
metadata: source.metadata,
thumbnail: source.thumbnail,
associatedAppId: source.associatedAppId,
isPublic: source.isPublic,
immutable: source.immutable,
});
}
const newUuid = uuidv4();
const sourceObjectKey = source.uuid;
const resolvedBucket = this.stores.s3Object.resolveBucket(
source.bucket,
);
await this.stores.s3Object.copyObject(
{
sourceBucket: resolvedBucket,
sourceKey: sourceObjectKey,
destinationBucket: resolvedBucket,
destinationKey: newUuid,
},
this.stores.s3Object.resolveRegion(source.bucketRegion),
);
// A ghost file — DB row present with a non-null bucket but its backing
// S3 object gone — would make CopyObject throw NoSuchKey and bubble up
// as a 500. Mirror `readContent`: clean up the orphan and surface a
// 404 instead. (`hasNoBackingS3Object` above only covers legitimately
// empty files, which keep a null bucket.)
try {
await this.stores.s3Object.copyObject(
{
sourceBucket: resolvedBucket,
sourceKey: sourceObjectKey,
destinationBucket: resolvedBucket,
destinationKey: newUuid,
},
this.stores.s3Object.resolveRegion(source.bucketRegion),
);
} catch (err) {
if (isNoSuchKeyError(err)) {
await this.#handleGhostFile(source, sourceObjectKey);
throw new HttpError(404, 'File contents are missing', {
legacyCode: 'subject_does_not_exist',
cause: err,
fields: {
path: source.path,
uid: source.uuid,
},
});
}
throw err;
}
const nextMetadata = this.#sanitizeClientMetadata(source.metadata);
@@ -171,6 +171,12 @@ export class PuterHomepageService extends PuterService {
const guiParams: Record<string, unknown> = {
...this.#guiParams,
...(this.config.gui_params ?? {}),
// The config flag wins, but an operator-set
// `gui_params.disable_temp_users` must survive the override.
disable_temp_users: Boolean(
this.config.disable_user_signup ||
this.config.gui_params?.disable_temp_users,
),
domain: this.config.domain,
env,
api_base_url: this.config.api_base_url,
+6
View File
@@ -18,6 +18,7 @@
*/
import { ACLService } from './acl/ACLService';
import { AppOriginBlocklistService } from './abuse/AppOriginBlocklistService';
import { AppPermissionService } from './apps/AppPermissionService';
import { RecommendedAppsService } from './apps/RecommendedAppsService';
import { SuggestedAppsService } from './apps/SuggestedAppsService';
@@ -47,6 +48,7 @@ import type { IPuterServiceRegistry } from './types';
declare module './types' {
interface IPuterServiceInstances {
metering: MeteringService;
appOriginBlocklist: AppOriginBlocklistService;
permission: PermissionService;
acl: ACLService;
token: TokenService;
@@ -77,6 +79,10 @@ declare module './types' {
// BroadcastService is independent — only needs the event client.
export const puterServices = {
metering: MeteringService,
// Declared before `auth` so AuthService sees it as a prior peer — it
// queries the blocklist on app-token acquisition and per-request app
// token validation.
appOriginBlocklist: AppOriginBlocklistService,
permission: PermissionService,
acl: ACLService,
token: TokenService,
@@ -787,6 +787,45 @@ describe('MeteringService', () => {
expect(await target.hasAnyUsage(actor)).toBe(false);
});
it('does not double-charge same-month overage against remaining (usage total + consumed credits)', async () => {
const sub = await target.getActorSubscription(actor);
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
// Exhaust the allowance, then overspend by 1_000_000 — the overage
// is consumed from purchased credits.
await target.incrementUsage(
actor,
'kv:read',
1,
sub.monthUsageAllowance,
);
await target.incrementUsage(actor, 'kv:read', 1, 1_000_000);
await waitFor(async () => {
const addons = await target.getActorAddons(actor);
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
});
// The overage already lives in both this month's usage total and
// consumedPurchaseCredits; remaining must only be reduced once.
const allowed = await target.getAllowedUsage(actor);
expect(allowed.remaining).toBe(4_000_000);
});
it('counts consumed credits from prior months against the credit pool only', async () => {
// Simulate a prior-month overage: consumed credits exist but the
// current month has no usage (monthly usage keys roll over).
await target.updateAddonCredit(actor.user.uuid, 5_000_000);
await server.stores.kv.incr({
key: `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`,
pathAndAmountMap: { consumedPurchaseCredits: 2_000_000 },
});
const allowed = await target.getAllowedUsage(actor);
expect(allowed.remaining).toBe(
allowed.monthUsageAllowance + 3_000_000,
);
});
it('hasEnoughCredits compares remaining against the requested amount', async () => {
await target.updateAddonCredit(actor.user.uuid, 1_000);
expect(await target.hasEnoughCredits(actor, 100)).toBe(true);
@@ -835,18 +874,6 @@ describe('MeteringService', () => {
const { res } = await server.stores.kv.get({ key });
expect(res).toMatchObject({ purchasedCredits: 250 });
});
it('persists lastUpdated after an increment', async () => {
const userId = actor.user.uuid;
await target.incrementUsage(actor, 'kv:read', 1, 50);
await waitFor(async () => {
const { res } = await server.stores.kv.get({
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
});
expect(typeof res).toBe('number');
expect(res).toBeGreaterThan(0);
});
});
});
// ── Resolver registration ────────────────────────────────────────
@@ -238,14 +238,6 @@ export class MeteringService extends PuterService {
}),
);
this.handleAuxPromise(
'lastUpdated',
this.stores.kv.set({
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
value: Date.now(),
}),
);
const [actorUsages, actorSubscription, actorAddons] =
await Promise.all([
actorUsagesPromise,
@@ -401,13 +393,6 @@ export class MeteringService extends PuterService {
},
}),
);
this.handleAuxPromise(
'lastUpdated',
this.stores.kv.set({
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
value: Date.now(),
}),
);
const [actorUsages, actorSubscription, actorAddons] =
await Promise.all([
@@ -586,13 +571,6 @@ export class MeteringService extends PuterService {
},
}),
);
this.handleAuxPromise(
'lastUpdated',
this.stores.kv.set({
key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`,
value: Date.now(),
}),
);
return updated;
}
@@ -649,13 +627,21 @@ export class MeteringService extends PuterService {
],
);
const remaining = Math.max(
// Overage past the allowance is already charged to purchased credits
// via consumedPurchaseCredits, so the allowance and the credit pool
// must be netted separately — subtracting month usage AND consumed
// credits from one combined pool would charge the overage twice.
const remainingAllowance = Math.max(
0,
(userSubscription.monthUsageAllowance || 0) +
(addons?.purchasedCredits || 0) -
(currentMonthUsage.usage.total || 0) -
(userSubscription.monthUsageAllowance || 0) -
(currentMonthUsage.usage.total || 0),
);
const remainingPurchasedCredits = Math.max(
0,
(addons?.purchasedCredits || 0) -
(addons?.consumedPurchaseCredits || 0),
);
const remaining = remainingAllowance + remainingPurchasedCredits;
return {
remaining,
+11
View File
@@ -53,6 +53,17 @@ export interface FSEntry {
suggestedApps: unknown[]; // TODO DS: type with app row
}
/**
* True when an entry has no backing S3 object. Empty files (size 0) created
* via `touch`/`createNonFileEntry` never upload to S3 and leave `bucket` null;
* real files always store a non-null bucket on write, so a null bucket on a
* size-0 entry reliably means there is nothing in S3 to read or copy. A real
* file whose object went missing keeps its bucket, so this stays false and the
* usual missing-object handling still applies.
*/
export const hasNoBackingS3Object = (entry: FSEntry): boolean =>
(entry.size ?? 0) === 0 && entry.bucket === null;
export interface FSEntrySubdomain {
uuid: string;
address: string; // `${config.protocol}://${subdomain}.${'puter.site'|'puter.work'}` depending on wether dir or file
+7
View File
@@ -87,4 +87,11 @@ export class OIDCStore extends PuterStore {
[userId, provider],
);
}
async unlinkAllByUserId(userId) {
await this.clients.db.write(
'DELETE FROM `user_oidc_providers` WHERE `user_id` = ?',
[userId],
);
}
}
@@ -366,6 +366,50 @@ describe('SystemKVStore', () => {
expect(result.res).toMatchObject({ page: { views: 4 } });
});
it('folds expireAt into the incr and stamps it once', async () => {
const past = Math.floor(Date.now() / 1000) - 10;
// First bump creates the counter and stamps the (already-elapsed)
// ttl in the same write — no separate expireAt call.
await target.incr(
{ key: 'ttlCounter', pathAndAmountMap: { hits: 1 }, expireAt: past },
opts,
);
const result = await target.get({ key: 'ttlCounter' }, opts);
expect(result.res).toBeNull();
});
it('keeps the first expireAt stamp across later bumps (if_not_exists)', async () => {
const future = Math.floor(Date.now() / 1000) + 3600;
await target.incr(
{ key: 'ttlKeep', pathAndAmountMap: { hits: 1 }, expireAt: future },
opts,
);
// A later bump passing an already-elapsed ttl must NOT override the
// first stamp, so the counter stays visible.
const past = Math.floor(Date.now() / 1000) - 10;
await target.incr(
{ key: 'ttlKeep', pathAndAmountMap: { hits: 1 }, expireAt: past },
opts,
);
const result = await target.get({ key: 'ttlKeep' }, opts);
expect(result.res).toMatchObject({ hits: 2 });
});
it('creates nested intermediate maps lazily on the first bump, then accumulates', async () => {
// First bump into a missing nested parent must still build the map
// (optimistic path: the direct update fails, createPaths runs, retry
// succeeds), and subsequent bumps keep accumulating.
await target.incr(
{ key: 'lazyNest', pathAndAmountMap: { 'a.b.c': 2 } },
opts,
);
const after = await target.incr(
{ key: 'lazyNest', pathAndAmountMap: { 'a.b.c': 3 } },
opts,
);
expect(after.res).toMatchObject({ a: { b: { c: 5 } } });
});
it('decr subtracts via the same machinery', async () => {
await target.incr(
{ key: 'counter3', pathAndAmountMap: { hits: 10 } },
+49 -14
View File
@@ -506,7 +506,11 @@ export class SystemKVStore extends PuterStore {
}
async incr<T extends Record<string, number>>(
{ key, pathAndAmountMap }: { key: string; pathAndAmountMap: T },
{
key,
pathAndAmountMap,
expireAt,
}: { key: string; pathAndAmountMap: T; expireAt?: number },
opts?: KVOpts,
): Promise<
KVResult<T extends { '': number } ? number : RecursiveRecord<number>>
@@ -529,12 +533,6 @@ export class SystemKVStore extends PuterStore {
const actor = ensureActor(opts);
const namespace = getNamespace(actor, opts?.appUuid);
const createPathsUsage = await this.createPaths(
namespace,
key,
Object.keys(pathAndAmountMap),
);
const setStatements = Object.entries(pathAndAmountMap).map(
([valPath, _amt], idx) => {
const attrName = ['value', ...valPath.split('.')]
@@ -565,13 +563,50 @@ export class SystemKVStore extends PuterStore {
{} as Record<string, string>,
);
const response = await this.clients.dynamo.update(
this.tableName,
{ key, namespace },
`SET ${setStatements.join(', ')}`,
valueAttributeValues,
{ ...valueAttributeNames, '#value': 'value' },
);
// Fold the TTL into the same UpdateItem so a counter bump is a single
// write instead of incr + a separate expireAt. if_not_exists keeps the
// first stamp for the key (re-stamping the same value was always a
// no-op) — but now we don't pay for that extra write on every bump.
if (expireAt !== undefined) {
const ttlSeconds = Number(expireAt);
if (Number.isNaN(ttlSeconds))
throw new HttpError(400, 'kv: expireAt must be a number', {
legacyCode: 'bad_request',
});
setStatements.push('#ttl = if_not_exists(#ttl, :ttl)');
valueAttributeValues[':ttl'] = ttlSeconds;
valueAttributeNames['#ttl'] = 'ttl';
}
const updateExpression = `SET ${setStatements.join(', ')}`;
const expressionNames = { ...valueAttributeNames, '#value': 'value' };
const runUpdate = () =>
this.clients.dynamo.update(
this.tableName,
{ key, namespace },
updateExpression,
valueAttributeValues,
expressionNames,
);
// Most increments land on an item whose parent maps already exist (a
// day's counter is created once, then bumped on every event), so try
// the update directly and only pay the per-layer createPaths writes
// when a nested parent is genuinely missing — typically the first bump
// for a key. Mirrors the lazy ValidationException fallback in remove().
let createPathsUsage = 0;
let response;
try {
response = await runUpdate();
} catch (e) {
if ((e as Error)?.name !== 'ValidationException') throw e;
createPathsUsage = await this.createPaths(
namespace,
key,
Object.keys(pathAndAmountMap),
);
response = await runUpdate();
}
const usage = writeUsage(
Number(response.ConsumedCapacity?.CapacityUnits ?? 0) +
+34
View File
@@ -54,4 +54,38 @@ describe('UserStore', () => {
expect(typeof cachedUser?.email_confirmed).toBe('boolean');
expect(typeof cachedUser?.requires_email_confirmation).toBe('boolean');
});
it('counts other accounts holding the same phone number', async () => {
const phone = `+1415555${Math.floor(1000 + Math.random() * 9000)}`;
const makeUser = async () => {
const username = `up-${Math.random().toString(36).slice(2, 10)}`;
const user = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
});
await server.stores.user.update(user.id, { phone });
return user;
};
const first = await makeUser();
expect(
await server.stores.user.countOthersByPhone(phone, first.id),
).toBe(0);
const second = await makeUser();
expect(
await server.stores.user.countOthersByPhone(phone, first.id),
).toBe(1);
expect(
await server.stores.user.countOthersByPhone(phone, second.id),
).toBe(1);
// A caller that isn't among the holders sees both.
expect(await server.stores.user.countOthersByPhone(phone, -1)).toBe(2);
// Unknown number counts zero.
expect(
await server.stores.user.countOthersByPhone('+19995550000', -1),
).toBe(0);
});
});
+25
View File
@@ -37,6 +37,8 @@ export interface UserRow {
email?: string | null;
/** True when an admin has suspended the account. */
suspended?: boolean;
/** When the account was suspended, as unix seconds; null while not suspended. */
suspended_at?: number | null;
/** True when the user has confirmed the email currently on file. */
email_confirmed?: boolean;
/** True for accounts that must confirm email before taking most actions. */
@@ -255,6 +257,29 @@ export class UserStore extends PuterStore {
return this.getById(row.id as number);
}
/**
* Count accounts other than `excludeUserId` whose confirmed `phone`
* equals this number. Backs the cap on live accounts per phone number
* (the row is deleted with the account, so this only counts accounts
* that still exist — recycling velocity is rate-limited separately by
* the abuse policy's KV log).
*
* Not cached — like `getByCleanEmail`, callers use this for duplicate
* detection at write time, which needs fresh reads.
*/
async countOthersByPhone(
phone: string,
excludeUserId: number,
): Promise<number> {
if (!phone) return 0;
if (!isStorableAsLatin1(phone)) return 0;
const rows = (await this.clients.db.tryHardRead(
'SELECT COUNT(*) AS n FROM `user` WHERE `phone` = ? AND `id` != ?',
[phone, excludeUserId],
)) as Array<{ n: number | string }>;
return Number(rows[0]?.n) || 0;
}
/**
* Generic property lookup. Fast-path reads redis first (cache is
* multi-key — every identifying property points at the same serialized
+2 -2
View File
@@ -20,7 +20,7 @@
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
@@ -37,7 +37,7 @@ const endpoint =
const sampleRatio = Number(process.env.OTEL_TRACE_SAMPLE_RATIO ?? 0.05);
const sdk = new NodeSDK({
resource: new Resource({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? 'puter-backend',
[ATTR_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0',
'deployment.environment': process.env.NODE_ENV ?? 'development',
+37
View File
@@ -397,6 +397,13 @@ export interface IGuiParams {
title?: string;
short_description?: string;
social_media_image?: string;
/**
* Public SDK key for Prelude's JS Signals SDK. When set, the phone-
* verification window collects a `dispatch_id` (browser signals) and
* forwards it to /send-confirm-phone, which passes it to Prelude's Verify
* API so its abuse model can weigh the device. Omit to disable collection.
*/
preludeSdkKey?: string;
[key: string]: unknown;
}
@@ -561,6 +568,12 @@ interface IConfigOptional {
min_pass_length: number;
/** When true, allow the 'system' user to log in. */
allow_system_login: boolean;
/**
* When true, anonymous users cannot create new accounts or temporary
* sessions. Existing accounts can still log in, and pre-existing
* placeholder rows may still be claimed.
*/
disable_user_signup: boolean;
/** Reject auth-gated routes unless the user has confirmed their email. */
strict_email_verification_required: boolean;
/**
@@ -577,6 +590,30 @@ interface IConfigOptional {
* signups). Requires a payments extension to actually run the $0 auth.
*/
always_require_card_verification: boolean;
/**
* Let a user who keeps getting blocked on SMS phone verification fall
* back to credit-card verification, which clears the phone gate (and the
* card gate too, when one is set). Off by default.
*
* The fallback opens after `after_attempts` SMS *send* attempts inside
* the send rate-limit window — successful sends count too, so a user who
* receives codes fine can still choose the card path after that many
* requests. This trades the phone signal for a card signal; it does NOT
* guarantee SMS actually failed. Once open, the fallback stays open for
* 24 hours so the user can finish the card flow. Requires a payments
* extension to run the actual card check.
*/
phone_verification_card_fallback: {
enabled: boolean;
/**
* SMS send attempts (within the send rate-limit window) before the
* card fallback opens. Defaults to 2 when omitted. Values above the
* send route's rate limit (10/hour) are clamped down to it — requests
* past the route limit never reach the attempt counter, so a higher
* threshold could never be crossed.
*/
after_attempts?: number;
};
/** Captcha configuration. */
captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' };
/** OIDC / OAuth2 providers (google + custom). */
+2 -2
View File
@@ -152,8 +152,8 @@
</ol>
<span class="close-message" id="earn-money-c2a-close" data-target="#earn-money">✕</span>
<hr>
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:hi@puter.com"
style="outline: none;">hi@puter.com</a></span>
<span style="font-size:15px;">Questions? Contact us: <a href="mailto:support@puter.com"
style="outline: none;">support@puter.com</a></span>
<a style="font-size: 14px; float: right; outline: none;" href="https://puter.com/incentive-program-terms"
target="_blank">Incentive Program Terms</a>
</dialog>
+11 -11
View File
@@ -153,7 +153,7 @@ Pass in the `tools` parameter with the type of `web_search`.
```js
{
model: 'openai/gpt-5.2-chat',
model: 'openai/gpt-5.3-chat',
tools: [{type: "web_search"}]
}
```
@@ -214,7 +214,7 @@ is an opaque payload; treat it as a black box and just carry it forward.
```js
const resp = await puter.ai.chat(messages, {
model: 'gpt-5.4', // or 'claude-opus-4-8' — same code
model: 'gpt-5.5', // or 'claude-opus-4-8' — same code
stream: true,
compaction: { trigger_tokens: 60000 },
});
@@ -241,7 +241,7 @@ if ( compaction ) {
},
{ role: 'user', content: 'now compare the two approaches' },
],
{ model: 'gpt-5.4', stream: true, compaction: true }
{ model: 'gpt-5.5', stream: true, compaction: true }
);
for await ( const part of next ) {
if ( part.type === 'text' ) document.write(part.text);
@@ -253,7 +253,7 @@ if ( compaction ) {
```js
const result = await puter.ai.chat(messages, {
model: 'gpt-5.4',
model: 'gpt-5.5',
compaction: { trigger_tokens: 60000 },
});
console.log(result.message.content);
@@ -274,7 +274,7 @@ if ( result.compaction ) {
},
{ role: 'user', content: 'now compare the two approaches' },
],
{ model: 'gpt-5.4', compaction: true }
{ model: 'gpt-5.5', compaction: true }
);
console.log(next.message.content);
}
@@ -369,7 +369,7 @@ Image generation works with `stream: true`. Image chunks arrive as `image` event
```js
const resp = await puter.ai.chat("Draw a cat", {
model: "gemini-2.5-flash-image",
model: "gemini-3.1-flash-image-preview",
stream: true,
});
@@ -442,7 +442,7 @@ for await (const part of resp) {
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-2.5-flash-lite', stream: true });
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-3.1-flash-lite', stream: true });
for await ( const part of resp ) document.write(part?.text.replaceAll('\n', '<br>'));
})();
</script>
@@ -602,7 +602,7 @@ for await (const part of resp) {
puter.print(`Loading...`);
puter.ai
.chat("Summarize what the User-Pays Model is: https://docs.puter.com/user-pays-model/", {
model: "openai/gpt-5.2-chat",
model: "openai/gpt-5.3-chat",
tools: [{ type: "web_search" }],
})
.then(puter.print);
@@ -643,7 +643,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
},
{ role: "user", content: question },
],
{ model: "claude-sonnet-4-5" }
{ model: "claude-sonnet-4-6" }
);
return response.message.content[0].text;
}
@@ -676,7 +676,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
(async () => {
puter.print("Generating image...<br>");
const result = await puter.ai.chat("Draw a cute cat wearing a top hat", {
model: "gemini-2.5-flash-image",
model: "gemini-3.1-flash-image-preview",
});
puter.print(result.message.content + "<br>");
if (result.message.images?.length > 0) {
@@ -859,7 +859,7 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
}
]
}
], { model: 'claude-sonnet-4', stream: true });
], { model: 'claude-sonnet-4-6', stream: true });
let text = '';
+3 -3
View File
@@ -27,10 +27,10 @@ Example model entry:
```json
[
{
"id": "claude-opus-4-5",
"id": "claude-opus-4-8",
"provider": "claude",
"name": "Claude Opus 4.5",
"aliases": ["claude-opus-4-5-latest"],
"name": "Claude Opus 4.8",
"aliases": ["claude-opus-4-8-latest"],
"context": 200000,
"max_tokens": 64000,
"cost": {
+36 -14
View File
@@ -1,6 +1,6 @@
---
title: puter.ai.txt2img()
description: Generate images from text prompts using AI models like GPT Image, Nano Banana, DALL-E 3, Grok Image, or FLUX.
description: Generate images from text prompts using AI models like GPT Image, Nano Banana, Grok Image, or FLUX.
platforms: [websites, apps, nodejs, workers]
---
@@ -32,19 +32,36 @@ Additional settings for the generation request. Available options depend on the
|--------|------|-------------|
| `prompt` | `String` | Text description for the image generation |
| `provider` | `String` | The AI provider to use. `'openai-image-generation' (default) \| 'gemini' \| 'together' \| 'xai' \| 'replicate-image-generation'` |
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-2-image'` when `provider: 'xai'` |
| `model` | `String` | Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` (OpenAI) or `'grok-imagine-image'` when `provider: 'xai'` |
| `test_mode` | `Boolean` | When `true`, returns a sample image without using credits |
| `puter_output_path` | `String` | When set, the generated image is automatically saved to this path on the Puter filesystem. Relative paths are resolved against the app's data directory (or `~/` outside an app). The caller must have write permission to the destination |
| `input_images` | `Array<String>` | Input image(s) for image-to-image — the canonical, cross-provider field (see below). |
| `input_image` | `String` | Single-image shorthand for `input_images`. |
#### Input images (image-to-image)
`input_images` is the universal way to pass image-to-image inputs across providers; `input_image` is the single-image shorthand. Each entry may be a **public URL**, a **data-URI**, or **raw base64** — providers that need base64 fetch URLs server-side (SSRF-guarded), so a URL works everywhere.
| Provider | Multiple images? | Accepted input forms |
|----------|------------------|----------------------|
| OpenAI `gpt-image-*` | Yes | URL, base64 / data-URI |
| Gemini | Yes | URL, base64 / data-URI |
| Replicate | Yes (model-dependent) | URL, base64 / data-URI |
| xAI `grok-imagine-*` | Up to 3 | URL, base64 / data-URI |
| Together | Single only (400 if more than one) | URL, base64 / data-URI |
| Cloudflare | Single only (400 if more than one) | URL, base64 / data-URI (only some models use it) |
#### OpenAI Options
Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`):
Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`):
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` |
| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`. For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) |
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models and DALL-E are restricted to fixed sizes |
| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'` |
| `quality` | `String` | Image quality: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'` |
| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models are restricted to fixed sizes |
| `input_image` | `String` | An input image for image-to-image editing — a URL or base64/data-URI (URLs are fetched server-side). |
| `input_images` | `Array<String>` | Multiple input images (URL or base64/data-URI) for image-to-image editing. Routes the request through OpenAI's image edit endpoint. |
For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create).
@@ -57,16 +74,19 @@ Available when `provider: 'gemini'` or inferred from model:
| `model` | `String` | Image model to use. |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
| `quality` | `String` | Output size tier: `'512'`, `'1K'`, `'2K'`, `'4K'` (availability varies by model) |
| `input_images` | `Array<String>` | Base64 input images for image-to-image (Gemini models only) |
| `input_images` | `Array<String>` | Input images for image-to-image — a URL or base64/data-URI (URLs are fetched server-side). |
#### xAI (Grok) Options
Available when `provider: 'xai'` or inferred from model (`grok-2-image`, alias `grok-image`):
Available when `provider: 'xai'` or inferred from model (`grok-imagine-image`, alias `grok-image`):
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | Image model to use. Available: `'grok-2-image'` (default) |
| `prompt` | `String` | Text prompt for the image. Grok Image does not support quality/size overrides; pricing is $0.07 per generated image. |
| `model` | `String` | Image model to use. Available: `'grok-imagine-image'` (default), `'grok-imagine-image-quality'` |
| `prompt` | `String` | Text prompt for the image (or the edit instruction when input images are supplied). |
| `quality` | `String` | Output resolution tier: `'1k'` (default) or `'2k'`. |
| `input_image` | `String` | A public URL or base64-encoded (data-URI) input image for image-to-image editing. |
| `input_images` | `Array<String>` | Up to 3 input images (URLs or base64/data-URI) for multi-image editing — combine subjects, transfer styles, compose scenes. Routes through xAI's image edit endpoint. |
#### Together Options
@@ -82,6 +102,8 @@ Available when `provider: 'together'` or inferred from model:
| `seed` | `Number` | Seed used for generation. Can be used to reproduce image generations |
| `negative_prompt` | `String` | The prompt or prompts not to guide the image generation |
| `n` | `Number` | Number of image results to generate. Default: `1` |
| `input_images` | `Array<String>` | Image-to-image input — **single image only** (400 if more than one). A URL is routed to `image_url`; base64/data-URI to `image_base64`. |
| `input_image` | `String` | Single-image shorthand for `input_images`. |
| `image_url` | `String` | URL of an image to use for image models that support it |
| `image_base64` | `String` | Base64 encoded input image for image-to-image generation |
| `mask_image_url` | `String` | URL of mask image for inpainting |
@@ -102,8 +124,8 @@ Available when `provider: 'replicate-image-generation'` or inferred from model:
|--------|------|-------------|
| `model` | `String` | Model id (e.g. `'black-forest-labs/flux-schnell'`, `'leonardoai/lucid-origin'`). |
| `ratio` | `Object` | Aspect ratio as `{ w, h }` (e.g., `{ w: 16, h: 9 }`). |
| `input_image` | `String` | URL of an input image for image-to-image generation. |
| `input_images` | `Array<String>` | Array of input image URLs for multi-image generation. |
| `input_image` | `String` | Input image for image-to-image generation — a URL or base64/data-URI. |
| `input_images` | `Array<String>` | Input images (URL or base64/data-URI) for multi-image generation. |
##### Per-model options
@@ -173,7 +195,7 @@ A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points
<script>
// Generate an image of a cat playing piano using a specific model and quality set to low
puter.ai.txt2img("a cat playing the piano", {
model: "gpt-image-1.5",
model: "gpt-image-2",
quality: "low"
}).then((image)=>{
document.body.appendChild(image);
@@ -191,7 +213,7 @@ A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points
<script src="https://js.puter.com/v2/"></script>
<script>
puter.ai.txt2img("a cat playing piano", {
model: "gemini-2.5-flash-image-preview",
model: "gemini-3.1-flash-image-preview",
input_image: "iVBORw0KGgoAAAANSUhEUgAAAFsAAABbCAYAAAAcNvmZAAABWGlDQ1BJQ0MgUHJvZmlsZQAAKJF1kL1LA0EQxV/0JPiFESwtrjQSJcZolyImEkSLEBVNusvmvAiXuFxO1P/AQls7ITaCoNgI14qF2AsqVhYiWlkI12hYZxP1EsWB2fnxeDM7DNCmaJybCoBS2bYyqSl1OZtT/S/oRB8C9A5rrMLj6fQcWfBdW8O9gU/W6xE56+n0Yrc3tn+yfRByq8nH3F9/S3QV9Aqj+kEZYtyyAd8QcXrD5pI3iQcsWop4R7LR4KrkfIPP6p6FTIL4ijjAilqB+E7OzDfpRhOXzHX2tYPcvkcvL85LnXIQ05hFBFGMIQsVqX+80bo3gTVwbMHCKgwUYVNHnBQOEzrxDMpgGEWIOIIw5YS88e/beZpxBEw+EBx7mp4EnFf6WvO04DPQHwYuVa5Z2s9Ffa5SWRmPNLjbATr2hHhbAvxBoHYrxLsjRO0QaL8Hzt1PBAlkAaSoB8oAAABWZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAOShgAHAAAAEgAAAESgAgAEAAAAAQAAAFugAwAEAAAAAQAAAFsAAAAAQVNDSUkAAABTY3JlZW5zaG904ZG7uwAAAdRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDYuMC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+OTE8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFhEaW1lbnNpb24+OTE8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpVc2VyQ29tbWVudD5TY3JlZW5zaG90PC9leGlmOlVzZXJDb21tZW50PgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4K4RUGBAAAG9hJREFUeAHtXHt8VdWV/s65r4TwSAgQAiG85A2C2kIpFigoCFWoL1oftba1M/VFZ2gtU7XKtGXq0PZXR6rT4ovWKdWqg8hItYDCIA95KE8Bi7wSCBDCK+QmN/fec+b79r0nXtNUwNxk8sdd+Z2c195r7/3ttdZee+19roWCDS4y1CwI2M1SSqYQg0AG7GYUhAzYGbCbEYFmLCoj2RmwmxGBZiwqI9kZsJsRgWYsKiPZGbCbEYFmLCoj2RmwmxGBZiwqI9kZsJsRgWYsKiPZGbCbEYFmLKqFSHbK+oUuXZ/+AZbDc5wHzyZJTeK56088Qyxxb14qrRIl0ytP3XUynfi6arKXzkvLR81AqnULoSgxCLEuAoKgugEeFq8JjivgdOTyqGUnJEGH3usQ8b1Jz3wmrZ4pL99b5G2Rr87mnTpT/EQe+B6fxNOm+N9ywJbEWZFEG41kn+Y9gRWYVjaPLOJTxbM6IwmiUkv6jaQrv9IrDQ+R24rXulAnsqmmM3htNEZNF6/moxYCtsAjoPZh4hLHZQPiuKR/G3Tr1BEnzxzHzr+exNYdZ1B2Mp/IEEDkMT2l00cJd6p5fRadioBLBkZR3D4P+fkd4Q+GUH60EqWHSvHudot5WzM9gZbGOEECrqarJ5oP8DSB/UkV9tQzNY137TWW922P4we3FuC+ewaiXR4Bocr7CEZcZtX2oyZci1f/vBs/+flu7D5IG2y7aJMXxR3TCnD7zRejb3EubF8cPkqtZbPzZCXsOFx3JGojNt7/sAxPzd+FPy0pxwkBj7Y8ZHKS4wOvPgLeq7N5mLZ/VuNX19kw0zKhokomKyqz4PKZzcZIbZ0yvjuBboUhFBT44RDf8uNVKDlShW4dg1j1ylfQuTuTxHMQ8FNiY35mpyz4Y3As8rRiBpZTZx3c98BS5OXlYtb9Y9AqxHfsEVcMVQ+W5dhKbwtrWD6W76hOem6j8kQN7v/XVfjNS8f4qDefy0RpvKC0Q7Zd7UiTDJJTKqUBbDVSh4iNMvZWjdMt7W5OOb4ysROuvboYw4cVomPbIMFkMg564UgcFccpwcFadCwiyGxnkKBGnRhOhbOx/f2jKChsix5dchDiSwouS7Bw1qml1Q4gO2DTDPMhbXE85oNDwE9X2/hwz3FU10TQv29XtG8XZ38zl02JZ6c5rF80EsPcX2/C/fP2AJXsYUs2XYBzXBDQrINKSjelAWxJgkiV0yFEONDlnsJjP7wIt107AK1DLmxKmqQrZsTNpZTadNwk9REC74MvpsEtgBVrj+HfH9uNZatP8r4DjzOUYhdfv6EjHrzvUuRmBRFXNoITICaReBCL3jyAFxcdwGurqlB9qpJ5OvJgeVYFkOXD5JEdMGVSe9w+rR98tO+wWtEDdPDWqqOYeMcajqkXMb0EJunJGK8l/dKdBrCNcUxWVkBXo2f34/iveWPw2f55lBMXEaJiU1398SjvYtR6Gz5KpayLRam0BAylckdJJQZPfBsIayCUm8bGS71lIqxj+P2jHXD91B4IZeUjznxnqyzM+ukazP09TZTLjrHoGrrKp05X5wrAM7yUGQNuvMrBY78ag7w2EaYIwE/eP/31G3h4zikm7ZfIZ9xKgd4iJZv1UktMw6oxaZyD558ai9ZZlTSFrVAr9aXt3rjxNBYvLcGmndU4Vk7XzHWRl9saQwZn4ctTu6JfvwJcP+1VvLOpG9tJkORzy782NjSCUZ89jOULr0WQWkBjgM17zmDcV9fj5DGla8d0TCvXzmAkbdMFVUCdZYV5rWdUhZxqvPmHwRh9SR7iwQBilS6uvuU5vLW+kOk6MQ0l3+XgacrlbRopPZKtRlISLxt2EisWTUBOVi3bzsGo1sY7G09i5sNb6X5pMlLAhnhACEhNTugZOGxgG9rLswRHEm0Aa8O0dOuoG2077sXOVdejc65jJPpoWRgjJ72B0mNF5j3/XQCRf6sSbFs+CQO75dCWR7BnfxXG3PAiyg5fmuycprHZ0rVGEkGjSrbKr8CfF01Cjo/2OpZDUEJ4Zn4JrrxhJd7dQenytU+CKEBlD3XkJM7yf2kSjFegyYsmIyDQAp1m4fapxchvV2MEPspn989ejdKjBNp4EExzIaQ81d3x7e++xYE4jhg1pVdxa1w3iVLtUgOM/y2w00+NB9sluG4VnpjdF205g4s7IdRwEPrlb7bhnx4+yBp353uCyoEsYXsFKqU7dcR35fcKYHaAiV3w0mgAzxzkbpvWgx5giN5GHBvWn8BzCymdNrXi06i6MVFRrHsnG4/P34qYes8J4JZrh1MgOKBCA6xsdvrpAsHWgJN68JaS2LX7CVw/qRh06lBtn8WLL57AzJ/tIyD0YY3NlPvAfPKXTX4VS2k2tl5Ta0qUJFmzO9lVM21n55A6FACDBtCkmJiIjZ/PWcJ0nXgvM+PVxeOr+/MgE3dpj/kvVxiX0LWrMWxQZ3TtxDpoYDUxGmlsKv/z4HuOJBcItrjJ5qoiSbJj+PpVXRAI0iULOojUZOGBRzYRG6KkmIWJVyi9APEONUJ8PNDVGR5f5eF74yqG0a+4FafefliBICrPxrB6h/KqI2TbyVfjhRkz+Mh4MDp/EqkOLJf12sZxJE5tclhWKOBDn14SDr2XtrLTTcxFLmlKez+J9TnefQqwPcDEmQ13zmDq1E7Eh5WKRbDghQ84cMk7IIDG9sr+CaBU8nh4Z76jKhuAFUSiuchvX0a/2MaP7+9P31iNt1HrRHHDxBC6dN7G+6PMxPyGhQDhcd42XM1mh8VD2PzeQdjUQIvgDujBDoQCYKy7N/M1M2HVv34b+OgC6VOArSxqYbIC/goMvrgL/FaQcYlsvLHsEN+xIfS3E2kkhUp/DtJkRx3Ghk65qgZb1k3CvP/4HMaObMuSAuTAWWaujXm/+jI2LL0Rt3xZQB9gHkm4XDXViyCdkzzQlNbPOEmYEy6ZrgAmjO6F4sKd7GxJNetj0f+2NJ6kx4ZfINgCLRU4B9OmFLGyPsSo+pEYB7D3vQFGNpmAGzvsNZCPGiTylARllePV54rwwjPD0THb4qBIkKPZiBAIl/EON55F4P0oaB/C03OvxLO/6o8OeR8wryYlaook/FykuiRNFmeKLjvJpSbV+qOYOmUw1v/lm7jmqv1MQwl3NVZIaKhZaaALBDspzXWAu+hAabPo7vn4Knw2gvIqSQUlQYexvWpYfRIfNYBpHbmAMgGl+N0v+mLiFzsxPpKNANU6Eo/hpONg0dL9+O2CHThWRVBoqtyoePowbdpgPPfrkRRQapNmieJ3TqJEJ6auLLs1DpywEI4lOtth5+a3aYX5/zkBYy6p4HvOPtnRCU8pVcjOWUiDCS4QbPEQUKJE4WLgJ7AK8kSNbWWjTWPUeI99/YqKh0BWwwWQg1tv5lR8Sm/4GWuOUkoPMDr3j99fjfzBb+Cmr+3AXTNKUdhvGaZ9Yyu27T9q+jHE6f/Y0T1xzXhO0+PqPJ7PSSybvrUxc6ze9OmHMXrscjz+2+30ieKIc8BvF2jNaf2XUNTlQKK5FkGva/c5C/i7CTw0/m6Cc72IM65snAJKoM2AfcK9E4AC1OuY+lzUYNlySo3NATF0GnMeGoFsTYio0mXHazDm6pX4/UtMd5YxD3Tl0Z28C7D4TeDSiRuweuNR0ymPPflXLF6ugY0damacvPxEko2nZmhKLLine truncated
input_image_mime_type: "image/png"
}).then((image)=>{
+2
View File
@@ -31,6 +31,7 @@ A string invite code created by `puter.peer.serve()`.
`options` is an object with the following properties:
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges may apply.
## Return value
@@ -41,6 +42,7 @@ A `Promise` that resolves to a `PuterPeerConnection` instance.
- `send(data)` - Send a message to the peer. Supports strings, `Blob`, `ArrayBuffer`, or `ArrayBufferView`.
- `close(reason)` - Close the connection.
- `owner` (`object`) - Information about the user who created the server.
- `peerconnection` (`RTCPeerConnection`) - the raw underlying RTC handle for this connection.
- `open` event: Fired when the data channel is ready.
- `message` event: Fired when a message is received (`event.data`).
- `close` event: Fired when the connection closes (`event.reason`).
+1
View File
@@ -27,6 +27,7 @@ const server = await puter.peer.serve(options);
`options` is an object with the following properties:
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges will increase.
## Return value
+16 -15
View File
@@ -75,21 +75,22 @@ $(document).on('pathchange', function (e) {
$(this).find('.icon').html(icons[$(this).data('icon-active')]);
});
// highlight code
$('code[class^=\'language\']').each(function () {
var $this = $(this);
if ( $this.attr('data-highlighted') === 'yes' ) {
// Remove the attribute or set it to 'no'
$this.removeAttr('data-highlighted');
}
// Now you can re-highlight
else {
try {
hljs.configure({ ignoreUnescapedHTML: true });
hljs.highlightElement(this);
} catch (e) {
console.error('Error: Failed to highlight.', e);
}
// Highlight code. Match the same `pre code` selector hljs.highlightAll()
// uses on full page load — selecting only `code[class^="language"]` skips
// fenced blocks written without a language tag, so they stayed unhighlighted
// after client-side navigation while showing up correctly on a hard reload.
// Scope to `.docs-content` so we only re-highlight the swapped-in docs body
// and leave code blocks elsewhere on the page untouched.
hljs.configure({ ignoreUnescapedHTML: true });
$('.docs-content pre code').each(function () {
// Skip blocks hljs has already processed so repeated pathchanges don't
// log "Element previously highlighted" and re-run on unchanged nodes.
if ( $(this).attr('data-highlighted') === 'yes' ) return;
try {
hljs.highlightElement(this);
} catch (e) {
console.error('Error: Failed to highlight.', e);
}
});
});
+2 -2
View File
@@ -44,8 +44,8 @@ const examples = [
title: 'AI',
children: [
{
title: 'Chat with GPT-5 nano',
description: 'Chat with GPT-5 nano using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
title: 'Chat with GPT-5.4 nano',
description: 'Chat with GPT-5.4 nano using Puter.js AI API. Run and experiment with this chatbot example directly in the playground.',
slug: 'ai-chatgpt',
source: '/playground/examples/ai-chatgpt.html',
},
+3 -3
View File
@@ -13,7 +13,7 @@ description: Find examples of serverless applications built with Puter.js
</a>
<div class="example-card-desc">
<h2 id="ai-chat"><a href="/playground/app-ai-chat/?autorun=1" target="_blank">AI Chat</a></h2>
<p>A chat app with AI using the Puter AI module. This app is powered by OpenAI GPT-5 nano.</p>
<p>A chat app with powered by Puter.js AI API.</p>
</div>
</div>
@@ -50,7 +50,7 @@ description: Find examples of serverless applications built with Puter.js
</a>
<div class="example-card-desc">
<h2 id="image-desc"><a href="/playground/app-camera/?autorun=1" target="_blank">Image Describer</a></h2>
<p>Allows you take a picture and describe it using the Puter AI module. This app is powered by OpenAI GPT-5 Vision.</p>
<p>Allows you to take a picture and describe it using the Puter.js AI API.</p>
</div>
</div>
@@ -62,7 +62,7 @@ description: Find examples of serverless applications built with Puter.js
</a>
<div class="example-card-desc">
<h2 id="text-summary"><a href="/playground/app-summarizer/?autorun=1" target="_blank">Text Summarizer</a></h2>
<p>Uses the Puter AI module to summarize a given long text. The model used in the background is GPT-5 nano.</p>
<p>Uses the Puter.js AI API to summarize a given long text.</p>
</div>
</div>
+1 -1
View File
@@ -222,7 +222,7 @@ Puter.js is powered by [Puter](https://github.com/HeyPuter/puter), the open-sour
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-2.5-flash-lite', stream: true });
const resp = await puter.ai.chat('Tell me in detail what Rick and Morty is all about.', {model: 'gemini-3.1-flash-lite', stream: true });
for await ( const part of resp ) puter.print(part?.text?.replaceAll('\n', '<br>'));
})();
</script>
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more