Compare commits

..
52 Commits
Author SHA1 Message Date
Nariman Jelveh f976686eba Update index.md 2026-05-16 13:12:31 -07:00
ProgrammerIn-wonderland 7af3721543 Make confirming an email unconfirm any other accounts using that email (#3121) 2026-05-16 15:07:38 -04:00
ProgrammerIn-wonderland 93edec0d01 assert normalized (#3119) 2026-05-15 17:01:32 -04:00
Daniel Salazar 54b61f2e24 fix: bad cookie parse test (#3120)
* fix: deepseek alias

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

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

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

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

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

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

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

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

* Normalize origin checks for cookie auth

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

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

---------

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

* Fix error reporting in puterjs. Add precheck for puterOutputPath

* add tests

* add tests

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

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ We at **Puter** are committed to maintaining a secure experience for our users a
The following are in scope for this program:
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
* **`puter.com`**
* **`api.puter.com`**
+1 -1
View File
@@ -86,7 +86,7 @@ Connect with the maintainers and community through these channels:
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
- Security issues? [[email protected]](mailto:[email protected])
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
- Email maintainers at [[email protected]](mailto:[email protected])
We are always happy to help you with any questions you may have. Don't hesitate to ask!
+12 -9
View File
@@ -58,7 +58,10 @@ services:
fi
wait $$SERVER_PID
volumes:
- ./puter/data/valkey:/data
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
# everywhere else) — without it those distros deny container
# access to the bind mount and the service loops on EACCES.
- ./puter/data/valkey:/data:z
healthcheck:
test:
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
@@ -77,7 +80,7 @@ services:
MARIADB_USER: ${MARIADB_USER:-puter}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
volumes:
- ./puter/data/mariadb:/var/lib/mysql
- ./puter/data/mariadb:/var/lib/mysql:z
healthcheck:
# `healthcheck.sh` ships with the mariadb image; --connect verifies
# the server is accepting auth, not just listening on the socket.
@@ -102,7 +105,7 @@ services:
- "-dbPath"
- "/home/dynamodblocal/data"
volumes:
- ./puter/data/dynamo:/home/dynamodblocal/data
- ./puter/data/dynamo:/home/dynamodblocal/data:z
s3:
# RustFS — S3-compatible object storage. Drop-in alternative:
@@ -114,7 +117,7 @@ services:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
volumes:
- ./puter/data/s3:/data
- ./puter/data/s3:/data:z
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
# which preserves the Host header for S3 signature validation and
# rides the same TLS termination as Puter. Uncomment to also expose
@@ -180,7 +183,7 @@ services:
container_name: puter-ollama
restart: unless-stopped
volumes:
- ./puter/data/ollama:/root/.ollama
- ./puter/data/ollama:/root/.ollama:z
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
# ports:
@@ -257,9 +260,9 @@ services:
PGID: 1000
volumes:
# Drop your config.json here — see selfhosted/full-stack.md.
- ./puter/config:/etc/puter
- ./puter/config:/etc/puter:z
# Persistent runtime data (anything your config points at /var/puter).
- ./puter/data/puter:/var/puter
- ./puter/data/puter:/var/puter:z
healthcheck:
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
interval: 30s
@@ -279,9 +282,9 @@ services:
# Uncomment when you enable TLS in nginx/nginx.conf:
# - "${HTTPS_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
- ./puter/tls:/etc/nginx/tls:ro
- ./puter/tls:/etc/nginx/tls:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
interval: 10s
+12 -1
View File
@@ -1,7 +1,7 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
import type { Request, Response } from 'express';
import TimeAgo from 'javascript-time-ago';
import localeEn from 'javascript-time-ago/locale/en';
@@ -94,6 +94,8 @@ export const handleWhoami = async (
human_readable_age: user.timestamp
? timeago.format(new Date(user.timestamp as string))
: null,
metadata: user.metadata,
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
// OIDC revalidate URL for password-less accounts
@@ -171,6 +173,15 @@ export const handleWhoami = async (
/* best-effort */
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
delete subscription.offering.price_id;
}
res.json(details);
};
+12
View File
@@ -50,6 +50,18 @@ docker compose version >/dev/null 2>&1 \
mkdir -p "$PUTER_DIR"
cd "$PUTER_DIR"
mkdir -p puter/config puter/data puter/tls
# Pre-create per-service data dirs and make them writable by any UID.
# Several upstream images run as non-root inside the container (rustfs
# uses UID 10001; dynamo is pinned to 1000 in compose), and rustfs's
# entrypoint runs as that same non-root user so it can't chown an
# already-existing bind-mounted dir. On hosts where the user that ran
# this script has a UID that doesn't match — or where docker is running
# rootless — those containers loop on EACCES at startup. 0777 on the
# bind-mount roots sidesteps the mismatch without guessing each image's
# internal UID. (Docker Desktop on macOS/Windows papers over this with
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
log "install dir: $(pwd)"
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
+24
View File
@@ -6270,6 +6270,29 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
@@ -19206,6 +19229,7 @@
"open": "^10.2.0"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"webpack-cli": "^5.1.4"
@@ -17,8 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { existsSync, readFileSync } from 'fs';
import { basename, extname, join, resolve } from 'path';
import { existsSync, mkdirSync, readFileSync } from 'fs';
import { basename, dirname, extname, join, resolve } from 'path';
import { createContext, runInContext } from 'vm';
import type { IConfig } from '../../types';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
@@ -102,6 +102,10 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
: (this.config.database?.path ?? ':memory:');
const isNew = dbPath === ':memory:' || !existsSync(dbPath);
if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true });
}
this.db = new Database(dbPath);
await this.runMigrations(isNew);
+16 -28
View File
@@ -18,8 +18,8 @@
*/
import bcrypt from 'bcrypt';
import crypto from 'node:crypto';
import type { Request, RequestHandler, Response } from 'express';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { Controller, Get, Post } from '../../core/http/decorators.js';
@@ -41,8 +41,8 @@ import {
createSecret as otpCreateSecret,
verify as verifyOtp,
} from '../../services/auth/OTPUtil.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
@@ -94,7 +94,6 @@ export class AuthController extends PuterController {
// ── Login ───────────────────────────────────────────────────────
@Post('/login', {
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
})
@@ -191,7 +190,6 @@ export class AuthController extends PuterController {
// ── Login: OTP verification ─────────────────────────────────────
@Post('/login/otp', {
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-otp',
@@ -246,7 +244,6 @@ export class AuthController extends PuterController {
// ── Login: recovery code ────────────────────────────────────────
@Post('/login/recovery-code', {
subdomain: ['api', ''],
captcha: true,
rateLimit: {
scope: 'login-recovery',
@@ -314,7 +311,6 @@ export class AuthController extends PuterController {
// ── Signup ──────────────────────────────────────────────────────
@Post('/signup', {
subdomain: ['api', ''],
captcha: true,
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
})
@@ -455,17 +451,7 @@ export class AuthController extends PuterController {
// - skip temp-user creation via `event.no_temp_user = true`
// Listeners run sequentially so multi-signal checks (rate limit +
// IP reputation + domain reputation) can short-circuit cleanly.
const validateEvent: {
req: Request;
data: Record<string, unknown>;
ip: string | null;
email: string | undefined;
allow: boolean;
no_temp_user: boolean;
requires_email_confirmation: boolean;
message: string | null;
code: string | null;
} = {
const validateEvent = {
req,
data: body,
ip: ((req.headers?.['x-forwarded-for'] as string | undefined) ||
@@ -480,6 +466,7 @@ export class AuthController extends PuterController {
requires_email_confirmation: false,
message: null,
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
};
try {
await this.clients.event?.emitAndWait(
@@ -707,14 +694,13 @@ export class AuthController extends PuterController {
// ── Logout ──────────────────────────────────────────────────────
@Post('/logout', {
subdomain: ['api', ''],
requireAuth: true,
allowUnconfirmed: true,
antiCsrf: true,
})
async handleLogout(req: Request, res: Response): Promise<void> {
// Clear the session cookie
res.clearCookie(this.config.cookie_name);
res.clearCookie(this.config.cookie_name!);
// Remove the session (fire-and-forget)
if (req.token) {
@@ -837,6 +823,16 @@ export class AuthController extends PuterController {
email_confirm_token: null,
});
// Revoke confirmation from any other accounts sharing this
// email so only the account whose owner just proved inbox
// access retains verified status.
const canonical = cleanEmail(user.email!);
await this.stores.user.unconfirmOthersByEmail(
user.id,
user.email!,
canonical,
);
await promoteToVerifiedGroup(this.stores.group, this.config, user);
try {
@@ -1552,7 +1548,6 @@ export class AuthController extends PuterController {
// ── Anti-CSRF token generation ──────────────────────────────────
@Get('/get-anticsrf-token', {
subdomain: '',
requireAuth: true,
allowUnconfirmed: true,
})
@@ -2326,7 +2321,6 @@ export class AuthController extends PuterController {
// ── Session helpers ─────────────────────────────────────────────
@Get('/get-gui-token', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
})
@@ -2348,7 +2342,6 @@ export class AuthController extends PuterController {
}
@Get('/session/sync-cookie', {
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
})
@@ -2382,7 +2375,7 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
res.clearCookie(this.config.cookie_name);
res.clearCookie(this.config.cookie_name!);
res.clearCookie('puter_revalidation');
await this.#cascadeDeleteUser(userId);
res.json({ success: true });
@@ -2442,7 +2435,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-password',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'passwd',
@@ -2462,7 +2454,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-username',
{
subdomain: ['api', ''],
requireUserActor: true,
requireVerified: true,
rateLimit: {
@@ -2483,7 +2474,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/change-email',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'change-email-start',
@@ -2503,7 +2493,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/disable-2fa',
{
subdomain: ['api', ''],
requireUserActor: true,
rateLimit: {
scope: 'disable-2fa',
@@ -2523,7 +2512,6 @@ export class AuthController extends PuterController {
router.post(
'/user-protected/delete-own-user',
{
subdomain: ['api', ''],
requireUserActor: true,
allowUnconfirmed: true,
middleware: [
+4 -2
View File
@@ -20,6 +20,7 @@
import Busboy from 'busboy';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -90,7 +91,7 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
@Controller('/fs')
export class FSController extends PuterController {
override getReportedCosts(): Record<string, unknown>[] {
override getReportedCosts() {
return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({
usageType,
ucentsPerUnit,
@@ -1787,7 +1788,8 @@ export class FSController extends PuterController {
pathToNormalize = `/${username}${pathToNormalize.slice(1)}`;
}
let normalizedPath = pathPosix.normalize(pathToNormalize);
assertNormalized(pathToNormalize);
let normalizedPath = pathToNormalize;
if (!normalizedPath.startsWith('/')) {
normalizedPath = `/${normalizedPath}`;
}
@@ -43,6 +43,7 @@ import {
signingConfigFromAppConfig,
toLegacyEntry,
} from './legacyFsHelpers.js';
import { RouteOptions } from '../../core/http/index.js';
/**
* Legacy FS routes, implemented as thin shims over `FSService`.
@@ -79,11 +80,16 @@ export class LegacyFSController extends PuterController {
#additionalCache: RouterCache = new Map();
registerRoutes(router: PuterRouter): void {
const apiOptions = { subdomain: 'api', requireVerified: true } as const;
const apiOptions = {
subdomain: 'api',
requireVerified: true,
} as RouteOptions;
// Signed-URL routes: the handler validates the URL signature itself,
// so no auth gate is applied (matches v1, which mounted these routers
// with no middleware).
const signedOptions = { subdomain: 'api' } as const;
const signedOptions = {
subdomain: 'api',
} as RouteOptions;
// Core filesystem_api routes — direct handlers over the FS service.
router.post('/stat', apiOptions, this.stat);
@@ -98,7 +104,11 @@ export class LegacyFSController extends PuterController {
router.get('/read', apiOptions, this.read);
router.get(
'/token-read',
{ subdomain: 'api', requireVerified: false },
{
subdomain: 'api',
requireVerified: false,
allowAccessToken: true,
},
this.tokenRead,
);
@@ -23,7 +23,7 @@ import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { FSService } from '../../services/fs/FSService.js';
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
import type { Actor } from '../../core/actor.js';
import { isAppActor, type Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import type { EventClient } from '../../clients/event/EventClient.js';
import { HttpError } from '../../core/http/HttpError.js';
@@ -202,8 +202,8 @@ export async function assertAccess(
// App-under-user actors see denials as 404 "subject_does_not_exist"
// so existence of a sibling user's / other-app's files isn't leaked
// through the error code. User-actor denials keep the real 403.
const isAppActor = Boolean((actor as { app?: unknown })?.app);
if (isAppActor) {
if (isAppActor(actor)) {
throw new HttpError(404, `Entry not found: path=${path}`, {
legacyCode: 'subject_does_not_exist',
});
@@ -38,7 +38,7 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification.
* Sets `acknowledged` timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', { subdomain: 'api', requireAuth: true })
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
async markAck(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
@@ -77,7 +77,7 @@ export class NotificationController extends PuterController {
* POST /notif/mark-read — user saw a notification.
* Sets `shown` timestamp; pushes ack event to sockets.
*/
@Post('/mark-read', { subdomain: 'api', requireAuth: true })
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
async markRead(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
@@ -73,11 +73,6 @@ const makeUserActor = (): Actor => ({
user: { id: 7, uuid: 'u-7', username: 'alice' },
});
const makeAppActor = (): Actor => ({
user: { id: 7, uuid: 'u-7', username: 'alice' },
app: { id: 1, uid: 'app-uid' },
});
interface CapturedResponse {
statusCode: number;
body: unknown;
@@ -193,13 +188,23 @@ describe('PuterAIController.registerRoutes', () => {
]),
);
const chatRoute = calls.find(
(c) => c.path === '/puterai/openai/v1/chat/completions',
);
expect(chatRoute?.opts).toEqual({
subdomain: 'api',
requireAuth: true,
});
// The four upstream-proxy routes are user-only: app actors must
// call puter-chat-completion directly. The `requireUserActor`
// route option wires up the gate that enforces this, so each
// proxy route gets the assertion (not just chat/completions).
const userOnlyPaths = [
'/puterai/openai/v1/chat/completions',
'/puterai/openai/v1/completions',
'/puterai/openai/v1/responses',
'/puterai/anthropic/v1/messages',
];
for (const path of userOnlyPaths) {
const route = calls.find((c) => c.path === path);
expect(route?.opts).toEqual({
subdomain: 'api',
requireUserActor: true,
});
}
const modelsRoute = calls.find(
(c) => c.path === '/puterai/chat/models',
);
@@ -213,18 +218,10 @@ describe('PuterAIController.registerRoutes', () => {
// ── /openai/v1/chat/completions ─────────────────────────────────────
describe('PuterAIController.openaiChatCompletions', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiChatCompletions(
makeReq({
body: { messages: [], model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// Note: app-actor rejection (403) is enforced by the `requireUserActor`
// route gate and is asserted in the registerRoutes test above. The
// handler itself no longer does that check, so unit-testing it here
// would require invoking the gate stack.
it('rejects bodies missing a messages array with HttpError 400', async () => {
const { res } = makeRes();
@@ -272,13 +269,13 @@ describe('PuterAIController.openaiChatCompletions', () => {
const body = captured.body as Record<string, unknown>;
expect(body.object).toBe('chat.completion');
expect(body.model).toBe('gpt-test');
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
{
index: 0,
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
},
);
expect(
(body.choices as Array<Record<string, unknown>>)[0],
).toMatchObject({
index: 0,
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
});
// total_tokens is computed from prompt + completion.
expect(body.usage).toEqual({
prompt_tokens: 4,
@@ -392,18 +389,8 @@ describe('PuterAIController.openaiChatCompletions', () => {
// ── /openai/v1/completions ──────────────────────────────────────────
describe('PuterAIController.openaiCompletions', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiCompletions(
makeReq({
body: { prompt: 'hi', model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects a non-string prompt with HttpError 400', async () => {
const { res } = makeRes();
@@ -443,13 +430,13 @@ describe('PuterAIController.openaiCompletions', () => {
const body = captured.body as Record<string, unknown>;
expect(body.object).toBe('text_completion');
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
{
text: 'response',
index: 0,
finish_reason: 'stop',
},
);
expect(
(body.choices as Array<Record<string, unknown>>)[0],
).toMatchObject({
text: 'response',
index: 0,
finish_reason: 'stop',
});
expect((body.id as string).startsWith('cmpl-')).toBe(true);
});
});
@@ -457,18 +444,8 @@ describe('PuterAIController.openaiCompletions', () => {
// ── /openai/v1/responses ────────────────────────────────────────────
describe('PuterAIController.openaiResponses', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.openaiResponses(
makeReq({
body: { input: 'hi', model: 'gpt-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects providers other than openai-responses with HttpError 400', async () => {
const { res } = makeRes();
@@ -537,18 +514,8 @@ describe('PuterAIController.openaiResponses', () => {
// ── /anthropic/v1/messages ──────────────────────────────────────────
describe('PuterAIController.anthropicMessages', () => {
it('rejects app actors with HttpError 403', async () => {
const { res } = makeRes();
await expect(
controller.anthropicMessages(
makeReq({
body: { messages: [], model: 'claude-test' },
actor: makeAppActor(),
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
});
// App-actor rejection lives in the `requireUserActor` gate now;
// see the registerRoutes assertion.
it('rejects bodies missing a messages array with HttpError 400', async () => {
const { res } = makeRes();
@@ -655,9 +622,8 @@ describe('PuterAIController model listing', () => {
const captureGetHandler = (
path: string,
): ((req: Request, res: Response) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((p: string, _opts: unknown, h: never) => {
@@ -719,9 +685,8 @@ describe('PuterAIController videoProxy', () => {
req: Request,
res: Response,
) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((path: string, _opts: unknown, h: never) => {
@@ -799,8 +764,9 @@ describe('PuterAIController videoProxy', () => {
it('500s when url_signature_secret is not configured', async () => {
// Temporarily blank the secret so the controller hits the
// 500 branch instead of the constant-time-compare gate.
const cfg = (controller as unknown as { config: Record<string, unknown> })
.config;
const cfg = (
controller as unknown as { config: Record<string, unknown> }
).config;
const orig = cfg.url_signature_secret;
cfg.url_signature_secret = undefined;
try {
@@ -826,8 +792,9 @@ describe('PuterAIController videoProxy', () => {
// Hit the post-signature `provider !== 'gemini'` branch by
// computing a valid signature for a known fileId/expires combo
// and then sending a different provider in the query.
const cfg = (controller as unknown as { config: Record<string, unknown> })
.config;
const cfg = (
controller as unknown as { config: Record<string, unknown> }
).config;
const secret = cfg.url_signature_secret as string;
const fileId = 'abc-123';
const expires = String(Math.floor(Date.now() / 1000) + 60);
@@ -854,11 +821,16 @@ describe('PuterAIController videoProxy', () => {
});
it('500s when provider=gemini but no Gemini API key is configured', async () => {
const cfg = (controller as unknown as {
config: Record<string, unknown> & {
providers?: Record<string, Record<string, unknown> | undefined>;
};
}).config;
const cfg = (
controller as unknown as {
config: Record<string, unknown> & {
providers?: Record<
string,
Record<string, unknown> | undefined
>;
};
}
).config;
const secret = cfg.url_signature_secret as string;
const fileId = 'gemini-no-key';
const expires = String(Math.floor(Date.now() / 1000) + 60);
@@ -1425,7 +1397,11 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
);
const tools = completeSpy.mock.calls[0]![0].tools as Array<{
type: string;
function: { name: string; description: string; parameters: unknown };
function: {
name: string;
description: string;
parameters: unknown;
};
}>;
expect(tools[0]?.type).toBe('function');
expect(tools[0]?.function.name).toBe('lookup');
@@ -1483,9 +1459,7 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
res,
);
const body = captured.body as { content: Array<{ text: string }> };
expect(body.content).toEqual([
{ type: 'text', text: 'hello world' },
]);
expect(body.content).toEqual([{ type: 'text', text: 'hello world' }]);
});
it('reads tool_use blocks from message.content (not just message.tool_calls)', async () => {
@@ -1536,9 +1510,8 @@ describe('PuterAIController model details', () => {
const captureGetHandler = (
path: string,
): ((req: Request, res: Response) => Promise<void>) => {
let handler:
| ((req: Request, res: Response) => Promise<void>)
| null = null;
let handler: ((req: Request, res: Response) => Promise<void>) | null =
null;
const router = {
post: vi.fn(),
get: vi.fn((p: string, _opts: unknown, h: never) => {
@@ -17,19 +17,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import { isAppActor } from '../../core/actor.js';
import { RouteOptions } from '../../core/http/index.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import type {
ICompleteArguments,
IChatCompleteResult,
ICompleteArguments,
} from '../../drivers/ai-chat/types.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import { PuterController } from '../types.js';
const GEMINI_DOWNLOAD_BASE =
'https://generativelanguage.googleapis.com/download/v1beta/files';
@@ -49,7 +49,10 @@ const GEMINI_DOWNLOAD_BASE =
*/
export class PuterAIController extends PuterController {
registerRoutes(router: PuterRouter): void {
const apiAuthOpts = { subdomain: 'api', requireAuth: true } as const;
const apiAuthOpts = {
subdomain: 'api',
requireUserActor: true,
} as RouteOptions;
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
// Every route below carries the `/puterai` prefix for wire
@@ -229,8 +232,6 @@ export class PuterAIController extends PuterController {
req: Request,
res: Response,
): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -396,8 +397,6 @@ export class PuterAIController extends PuterController {
// ── /openai/v1/completions ──────────────────────────────────────
openaiCompletions = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -526,8 +525,6 @@ export class PuterAIController extends PuterController {
// ── /openai/v1/responses ────────────────────────────────────────
openaiResponses = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -838,8 +835,6 @@ export class PuterAIController extends PuterController {
// ── /anthropic/v1/messages ──────────────────────────────────────
anthropicMessages = async (req: Request, res: Response): Promise<void> => {
this.#rejectAppActor(req);
const body = asRecord(req.body);
const stream = !!body.stream;
@@ -1052,17 +1047,6 @@ export class PuterAIController extends PuterController {
});
return driver;
}
#rejectAppActor(req: Request): void {
// Proxy routes are user-only; apps must call puter-chat-completion directly.
if (isAppActor(req.actor)) {
throw new HttpError(
403,
'App actors may not proxy to upstream AI APIs',
{ legacyCode: 'forbidden' },
);
}
}
}
// ── Shared helpers ──────────────────────────────────────────────────
+4 -1
View File
@@ -92,7 +92,10 @@ export const PuterController =
public onServerShutdown() {
return;
}
public async getReportedCosts() {
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[] {
return [];
}
public registerRoutes(_router: PuterRouter) {}
+1 -1
View File
@@ -64,7 +64,7 @@ export const isSystemActor = (actor: Actor | undefined | null): boolean => {
};
export const isAppActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.app && !actor?.accessToken;
return !!actor?.app && !isAccessTokenActor(actor);
};
export const isAccessTokenActor = (
+1
View File
@@ -1,5 +1,6 @@
// NOT ALL OF THEM, ADD OLD ONES AS NEEDED, IF NEEDED. DO NOT ADD NEW ONES THOUGH.
export type LegacyErrorCodes =
| 'payment_account_not_set_up'
| 'unknown_error'
| 'disallowed_value'
| 'invalid_token'
@@ -80,6 +80,9 @@ declare global {
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
is_custom_domain?: boolean;
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
cookies?: Record<string, string>;
}
}
}
@@ -73,8 +73,33 @@ interface ReqInit {
query?: Record<string, unknown>;
handshakeQuery?: Record<string, unknown>;
actor?: Actor;
protocol?: string;
}
// Minimal stand-in for `cookie-parser` — splits on `;`, URL-decodes the
// value, and strips a pair of surrounding double quotes (matches the
// `cookie` package's behavior, which `cookie-parser` uses internally).
const parseCookieHeader = (header: string): Record<string, string> => {
const out: Record<string, string> = {};
for (const piece of header.split(';')) {
const eq = piece.indexOf('=');
if (eq < 0) continue;
const name = piece.slice(0, eq).trim();
if (!name) continue;
let value = piece.slice(eq + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
try {
value = decodeURIComponent(value);
} catch {
/* leave as-is */
}
out[name] = value;
}
return out;
};
const makeReq = (init: ReqInit = {}): Request => {
const headers: Record<string, string> = { ...(init.headers ?? {}) };
if (init.cookieHeader) headers.cookie = init.cookieHeader;
@@ -82,11 +107,15 @@ const makeReq = (init: ReqInit = {}): Request => {
body: init.body,
query: (init.query ?? {}) as Request['query'],
headers: headers as unknown as Request['headers'],
protocol: init.protocol,
header(name: string) {
// Express's `req.header()` is case-insensitive; mirror that.
return headers[name.toLowerCase()];
return headers[name.toLowerCase()] as unknown as string[] & string;
},
};
if (init.cookieHeader) {
req.cookies = parseCookieHeader(init.cookieHeader);
}
if (init.actor) req.actor = init.actor;
if (init.handshakeQuery) {
req.handshake = { query: init.handshakeQuery };
@@ -303,10 +332,7 @@ describe('createAuthProbe — cookie reading', () => {
cookieName: 'puter_token',
});
// Quoted + percent-encoded value: `"a b"` → `a b`
await runProbe(
probe,
makeReq({ cookieHeader: 'puter_token="a%20b"' }),
);
await runProbe(probe, makeReq({ cookieHeader: 'puter_token="a%20b"' }));
expect(stub.seenTokens).toEqual(['a b']);
});
@@ -319,6 +345,114 @@ describe('createAuthProbe — cookie reading', () => {
);
expect(stub.seenTokens).toEqual([]);
});
it('ignores session cookies on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
const { req } = await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://attacker.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBeUndefined();
});
it('still accepts bearer tokens on cross-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
authorization: 'Bearer header-tok',
host: 'api.puter.test',
origin: 'https://app.example',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['header-tok']);
});
it('keeps session cookies for same-origin browser requests', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('normalizes default ports when comparing browser origins', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test:443',
origin: 'https://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual(['session-abc']);
});
it('treats protocol mismatches as cross-origin for session cookies', async () => {
const stub = makeStubAuth();
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
await runProbe(
probe,
makeReq({
protocol: 'https',
headers: {
host: 'api.puter.test',
origin: 'http://api.puter.test',
},
cookieHeader: 'puter_token=session-abc',
}),
);
expect(stub.seenTokens).toEqual([]);
});
});
// ── Behavior when AuthService responds ──────────────────────────────
@@ -348,7 +482,7 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
expect(req.tokenAuthFailed).toBe(true);
});
it("never rejects — sets tokenAuthFailed even when AuthService throws", async () => {
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
const stub = makeStubAuth();
stub.setNext('throw');
const probe = createAuthProbe({ authService: stub.service });
+28 -29
View File
@@ -44,7 +44,7 @@ interface AuthProbeOptions {
* 1. `req.body.auth_token`
* 2. `Authorization: Bearer <token>` header
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
* 4. Session cookie
* 4. Session cookie, only when the browser request is same-origin
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
@@ -126,12 +126,13 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
return stripBearer(xApiKey);
}
// 4. Cookie (set by login flow for session tokens). We parse the
// Cookie header directly rather than depending on `cookie-parser`
// middleware — the probe only needs one named value.
if (cookieName) {
const cookieToken = readCookie(req, cookieName);
if (cookieToken) {
// 4. Cookie (set by login flow for session tokens). Do not let an
// arbitrary browser Origin spend an ambient session cookie against the
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
// available for cross-origin SDK requests.
if (cookieName && !isCrossOriginBrowserRequest(req)) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
}
}
@@ -157,27 +158,25 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
const stripBearer = (t: string): string => t.replace(/^Bearer\s+/i, '').trim();
/**
* Minimal cookie reader. Avoids pulling in `cookie-parser` for the one
* lookup the probe needs. Handles quoted values and URL-decodes the result.
*/
const readCookie = (req: Request, name: string): string | null => {
const header =
typeof req.header === 'function' ? req.header('cookie') : undefined;
if (!header || typeof header !== 'string') return null;
const target = `${name}=`;
for (const rawPair of header.split(';')) {
const pair = rawPair.trim();
if (!pair.startsWith(target)) continue;
let value = pair.slice(target.length);
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
const isCrossOriginBrowserRequest = (req: Request): boolean => {
const origin =
typeof req.header === 'function' ? req.header('origin') : undefined;
if (!origin) return false;
const host =
typeof req.header === 'function' ? req.header('host') : undefined;
if (!host) return true;
const protocol =
typeof req.protocol === 'string' && req.protocol.length > 0
? req.protocol
: undefined;
if (!protocol) return true;
try {
const requestOrigin = new URL(`${protocol}://${host.trim()}`).origin;
return new URL(origin).origin !== requestOrigin;
} catch {
return true;
}
return null;
};
+21
View File
@@ -114,6 +114,27 @@ export const requireUserActorGate = (): RequestHandler => {
};
};
export const requireNonAccessTokenGate = (): RequestHandler => {
return (req, _res, next) => {
const actor = req.actor;
if (!actor) {
next(rejectAuth(req));
return;
}
if (actor.accessToken) {
next(
new HttpError(
403,
'Access tokens are not allowed to access this resource',
{ legacyCode: 'forbidden' },
),
);
return;
}
next();
};
};
/** Built-in admin usernames that always pass `adminOnly`. */
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
@@ -21,6 +21,7 @@ import type { RequestHandler } from 'express';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import type { IConfig } from '../../../types';
import { assertNormalized } from '../../../services/fs/resolveNode.js';
/** Native-app subdomains served via `nativeAppStatic`. */
const NATIVE_APP_SUBDOMAINS = [
@@ -130,10 +131,8 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
? path.join(root, active, 'dist')
: path.join(root, active);
// req.path is already url-decoded by express; normalize strips any
// `..` segments before sendFile's `root` option enforces its own
// traversal guard.
const requested = path.normalize(req.path);
const requested = req.path;
assertNormalized(requested);
const absolute = path.join(appRoot, requested);
try {
@@ -28,6 +28,7 @@ import {
buildAppCenterFallback,
buildHostingConfig,
buildPrivateHostRedirect,
buildPublicHostRedirect,
getBootstrapToken,
hostMatchesPrivateDomain,
normalizeHost,
@@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => {
});
});
describe('buildPublicHostRedirect', () => {
// Mirror of buildPrivateHostRedirect — swaps the private hosting
// domain for the public one. Used when a non-private app (or no app
// at all) hits the private host, so a paid-→-free app's old
// `puter.app` URL still resolves on `puter.site`.
const cfg = buildHostingConfig({
domain: 'puter.localhost',
static_hosting_domain: 'site.puter.localhost:4100',
static_hosting_domain_alt: null,
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: null,
protocol: 'http',
} as unknown as IConfig);
const reqOf = (init: Partial<Request>): Request =>
({
hostname: init.hostname,
originalUrl: init.originalUrl,
protocol: init.protocol ?? 'http',
headers: init.headers ?? {},
}) as unknown as Request;
it('swaps the private hosting domain for the public one (preserving port + path + query)', () => {
const url = buildPublicHostRedirect(
reqOf({
hostname: 'beans.app.puter.localhost',
originalUrl: '/some/path?x=1',
}),
cfg,
);
expect(url).toBe(
'http://beans.site.puter.localhost:4100/some/path?x=1',
);
});
it("defaults the path to '/' when originalUrl is empty", () => {
const url = buildPublicHostRedirect(
reqOf({ hostname: 'beans.app.puter.localhost' }),
cfg,
);
expect(url).toBe('http://beans.site.puter.localhost:4100/');
});
it('returns null when no public hosting domain is configured', () => {
const noPublic = {
...cfg,
staticDomains: [],
staticDomainsRaw: [],
};
expect(
buildPublicHostRedirect(
reqOf({ hostname: 'beans.app.puter.localhost' }),
noPublic,
),
).toBeNull();
});
it('returns null for the bare private host (no subdomain to forward)', () => {
expect(
buildPublicHostRedirect(
reqOf({ hostname: 'app.puter.localhost' }),
cfg,
),
).toBeNull();
});
});
// ── Login bootstrap HTML ────────────────────────────────────────────
describe('renderLoginBootstrapHtml', () => {
@@ -564,6 +564,38 @@ export function buildPrivateHostRedirect(
void app; // reserved for future use (logging)
}
/**
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
* equivalent URL for a request that landed on the private hosting domain
* but doesn't belong there (non-private app, or no app at all). Used so a
* formerly-paid app that's now free (or a plain hosted site) resolves on
* `puter.site` instead of 404ing on `puter.app`.
*/
export function buildPublicHostRedirect(
req: Request,
config: PrivateHostingConfig,
): string | null {
const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0];
if (!publicDomain) return null;
const host = normalizeHost(req.hostname);
if (!host) return null;
const subdomain = subdomainFromHost(host, [
...config.staticDomains,
...config.privateDomains,
]);
if (!subdomain) return null;
try {
const protocol = config.protocol || req.protocol || 'https';
const base = `${protocol}://${subdomain}.${publicDomain}`;
const reqPath = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
return new URL(reqPath, base).toString();
} catch {
return null;
}
}
/** Redirect URL when private access is denied — lands on the app-center listing. */
export function buildAppCenterFallback(
app: AppLike,
@@ -114,6 +114,7 @@ const makeRes = () => {
const makeReq = (init: {
hostname: string;
path?: string;
originalUrl?: string;
protocol?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
@@ -121,6 +122,9 @@ const makeReq = (init: {
({
hostname: init.hostname,
path: init.path ?? '/',
// Redirect helpers use originalUrl (preserves query string).
// Default to `path` when caller doesn't care to distinguish.
originalUrl: init.originalUrl ?? init.path ?? '/',
protocol: init.protocol ?? 'http',
headers: init.headers ?? {},
cookies: init.cookies ?? {},
@@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => {
});
});
// ── Private hosting domain refusal ──────────────────────────────────
// ── Private hosting domain → public-host redirect ───────────────────
describe('createPuterSiteMiddleware — private hosting domain', () => {
it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => {
it('302s a subdomain on the private host with no private app to the equivalent puter.site URL (covers freed-paid-app bookmarks + plain hosted sites)', async () => {
// Owner exists, subdomain exists, but it has no associated
// private app. On the *private* host this must refuse, not serve.
// private app. On the *private* host this used to 404; now it
// mirrors the public→private redirect so a paid app whose price
// dropped to 0 still resolves on `puter.site` when accessed via
// its old `puter.app` URL.
const owner = await makeUser();
const sub = `leak-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
@@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
makeReq({
// Note: app.puter.localhost is the *private* hosting domain.
hostname: `${sub}.app.puter.localhost`,
path: '/some/deep/path.html',
}),
);
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Subdomain not found');
expect(out.redirected).toEqual({
status: 302,
url: `http://${sub}.site.puter.localhost/some/deep/path.html`,
});
});
it('uses the alt private hosting domain when configured', async () => {
it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => {
// Coverage for the `private_app_hosting_domain_alt` slot — same
// refusal logic, but via the alternate host that the deployment
// redirect logic, but via the alternate host that the deployment
// can use for legacy traffic.
const owner = await makeUser();
const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`;
@@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
mw,
makeReq({ hostname: `${sub}.apps.alt.localhost` }),
);
expect(out.redirected).toEqual({
status: 302,
url: `http://${sub}.site.puter.localhost/`,
});
});
it('falls back to 404 when no public hosting domain is configured (no leak)', async () => {
// Without a static_hosting_domain to redirect to we have no safe
// target; the original refusal must still apply so a public-app
// subdomain doesn't accidentally serve via the private host.
const owner = await makeUser();
const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
});
const mw = createPuterSiteMiddleware(
{
...hostingConfig,
static_hosting_domain: null,
} as unknown as IConfig,
{
clients: server.clients,
stores: server.stores,
services: server.services,
},
);
const { out } = await runMiddleware(
mw,
makeReq({ hostname: `${sub}.app.puter.localhost` }),
);
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Subdomain not found');
expect(out.redirected).toBeUndefined();
});
});
@@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('<html>hi</html>');
await writeFile(
owner.id,
`${homePath}/index.html`,
body,
'text/html',
);
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('default doc');
await writeFile(
owner.id,
`${homePath}/index.html`,
body,
'text/html',
);
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -657,11 +689,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
const body = Buffer.from('not a directory');
// Write a file and use ITS id as the subdomain's root_dir_id —
// the middleware must reject because root must be a directory.
await writeFile(
owner.id,
`${homePath}/Documents/somefile.txt`,
body,
);
await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`${homePath}/Documents/somefile.txt`,
);
@@ -733,12 +761,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
rootDirId: homeEntry!.id,
});
const body = Buffer.from('inside');
await writeFile(
owner.id,
`${homePath}/safe.txt`,
body,
'text/plain',
);
await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain');
const mw = buildMiddleware();
const { res, out } = makeRes();
+15 -2
View File
@@ -29,6 +29,7 @@ import {
buildAppCenterFallback,
buildHostingConfig,
buildPrivateHostRedirect,
buildPublicHostRedirect,
hostMatchesPrivateDomain,
renderLoginBootstrapHtml,
resolvePrivateAppForHostedSite,
@@ -344,8 +345,20 @@ export const createPuterSiteMiddleware = (
// third-party resources loaded from the app.
res.setHeader('Referrer-Policy', 'no-referrer');
} else if (privateHostingDomains.has(matched)) {
// Private host with no private app → refuse. Prevents a
// public-app subdomain from leaking via the private host.
// Non-private content landed on the private hosting domain —
// mirror of the private redirect above. Covers two cases:
// - a paid app that just flipped to free (is_private 1→0)
// whose old `puter.app` URL is still bookmarked/shared;
// - a plain hosted site that has no associated app.
// Redirect to the equivalent `puter.site` URL so the content
// resolves on the correct origin instead of 404ing.
const redirectUrl = buildPublicHostRedirect(req, hostingCfg);
if (redirectUrl) {
res.redirect(302, redirectUrl);
return;
}
// No public hosting domain configured — fall back to refusing
// rather than leaking via the private host.
res.status(404).type('text/plain').send('Subdomain not found');
return;
} else {
+4 -1
View File
@@ -80,12 +80,15 @@ export interface RouteOptions {
*/
subdomain?: string | string[];
/** Reject anonymous + suspended-user requests with 401/403. */
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
requireAuth?: boolean;
/** Reject app/access-token actors. Implies `requireAuth`. */
requireUserActor?: boolean;
/** Allows access-tokens */
allowAccessToken?: boolean;
/**
* Reject unless the actor's username is `admin`, `system`, or one of the
* extras in this array. `true` means just `admin`/`system`; an array adds
@@ -41,6 +41,7 @@ import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
import type {
IChatCompleteResult,
@@ -751,13 +752,13 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const openrouter = providers['openrouter'];
const openrouterKey = readKey(openrouter);
if (openrouterKey) {
this.#providers['openrouter'] = new OpenRouterProvider(
const alibaba = providers['alibaba'];
const alibabaKey = readKey(alibaba);
if (alibabaKey) {
this.#providers['alibaba'] = new AlibabaProvider(
{
apiKey: openrouterKey,
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
apiKey: alibabaKey,
apiBaseUrl: alibaba?.apiBaseUrl as string | undefined,
},
metering,
);
@@ -782,6 +783,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const openrouter = providers['openrouter'];
const openrouterKey = readKey(openrouter);
if (openrouterKey) {
this.#providers['openrouter'] = new OpenRouterProvider(
{
apiKey: openrouterKey,
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
},
metering,
);
}
// Fake provider — always available for testing
this.#providers['fake-chat'] = new FakeChatProvider();
}
@@ -0,0 +1,63 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Integration test for the Alibaba provider.
*
* Uses `qwen-turbo` (cheapest generally-available model). Skipped
* when `PUTER_TEST_AI_ALIBABA_API_KEY` is unset.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AlibabaProvider } from './AlibabaProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'AlibabaProvider (integration)',
() => {
it('returns a non-empty completion from qwen-turbo', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new AlibabaProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-turbo',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
max_tokens: 16,
}),
);
const text = (result as { message?: { content?: string } }).message
?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
});
},
);
@@ -0,0 +1,720 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for AlibabaProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs AlibabaProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. Alibaba is OpenAI-compatible so the OpenAI SDK is mocked at
* the module boundary; that's the real network egress point. The
* companion integration test (AlibabaProvider.integration.test.ts)
* exercises the real DashScope endpoint.
*/
import { Writable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { ALIBABA_MODELS } from './models.js';
import { AlibabaProvider } from './AlibabaProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { createMock, openAICtor } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
});
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.chat = { completions: { create: createMock } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = (config?: { apiKey?: string; apiBaseUrl?: string }) => {
const provider = new AlibabaProvider(
{ apiKey: 'test-key', ...config },
server.services.metering,
);
return { provider };
};
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
async *[Symbol.asyncIterator]() {
for (const item of items) {
yield item;
}
},
});
const makeCapturingChatStream = () => {
const chunks: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString('utf8'));
cb();
},
});
const chatStream = new AIChatStream({ stream: sink });
return {
chatStream,
events: () =>
chunks
.join('')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
};
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('AlibabaProvider construction', () => {
it('points the OpenAI SDK at the DashScope base URL with the configured key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL:
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
});
});
it('uses a custom base URL when configured', () => {
makeProvider({ apiBaseUrl: 'https://custom.endpoint/v1' });
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://custom.endpoint/v1',
});
});
});
// ── Model catalog ───────────────────────────────────────────────────
describe('AlibabaProvider model catalog', () => {
it('returns qwen-plus-latest as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
});
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(ALIBABA_MODELS);
});
it('list() flattens canonical ids and aliases', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
for (const m of ALIBABA_MODELS) {
expect(ids).toContain(m.id);
for (const a of m.aliases ?? []) {
expect(ids).toContain(a);
}
}
expect(ids).toContain('qwen-plus');
expect(ids).toContain('qwen/qwen-plus');
expect(ids).toContain('qwen-max');
expect(ids).toContain('qwen/qwen-max');
});
});
// ── Request shape ───────────────────────────────────────────────────
describe('AlibabaProvider.complete request shape', () => {
const baseCompletion = {
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('forwards model + messages and defaults max_tokens to 1000 when caller omits it', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('qwen-plus');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
it('respects an explicit max_tokens override', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
}),
);
expect(createMock.mock.calls[0]![0].max_tokens).toBe(256);
});
it('forwards temperature when supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.7,
}),
);
expect(createMock.mock.calls[0]![0].temperature).toBe(0.7);
});
it('omits the `tools` key entirely when no tools are supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect('tools' in args).toBe(false);
});
it('passes tool definitions through unchanged when supplied', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const tools = [
{
type: 'function',
function: {
name: 'lookup',
parameters: { type: 'object', properties: {} },
},
},
];
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
);
expect(createMock.mock.calls[0]![0].tools).toBe(tools);
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
);
const [nonStreamArgs] = createMock.mock.calls[0]!;
expect(nonStreamArgs.stream).toBe(false);
expect('stream_options' in nonStreamArgs).toBe(false);
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
);
const [streamArgs] = createMock.mock.calls[1]!;
expect(streamArgs.stream).toBe(true);
expect(streamArgs.stream_options).toEqual({ include_usage: true });
});
it('hoists Puter-style tool_use blocks into OpenAI tool_calls before sending', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call_1',
name: 'lookup',
input: { q: 'puter' },
},
],
},
],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.messages[0].content).toBeNull();
expect(args.messages[0].tool_calls).toEqual([
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: JSON.stringify({ q: 'puter' }),
},
},
]);
});
});
// ── Model resolution ────────────────────────────────────────────────
describe('AlibabaProvider model resolution', () => {
const baseCompletion = {
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('resolves an exact canonical id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen-max',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('qwen-max');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'alibaba:qwen-max',
expect.any(Object),
);
});
it('resolves an alias to its canonical id (alias rewriting)', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'qwen/qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('qwen-plus');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'alibaba:qwen-plus',
expect.any(Object),
);
});
});
// ── Non-stream completion ───────────────────────────────────────────
describe('AlibabaProvider.complete non-stream output', () => {
it('returns the first choice and runs the metered usage calculator', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 100, completion_tokens: 50 },
});
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(result).toMatchObject({
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
});
expect((result as { usage: unknown }).usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 0,
});
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 0,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('alibaba:qwen-plus');
expect(overrides).toEqual({
prompt_tokens: 100 * Number(qwenPlus.costs.prompt_tokens),
completion_tokens: 50 * Number(qwenPlus.costs.completion_tokens),
cached_tokens: 0,
});
});
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'lookup',
arguments: '{"q":"puter"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
});
const result = (await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
}),
)) as { message: { tool_calls?: unknown[] }; finish_reason: string };
expect(result.finish_reason).toBe('tool_calls');
expect(result.message.tool_calls).toEqual([
{
id: 'call_1',
type: 'function',
function: { name: 'lookup', arguments: '{"q":"puter"}' },
},
]);
});
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 7, completion_tokens: 3 },
});
await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage.cached_tokens).toBe(0);
expect(overrides).toMatchObject({ cached_tokens: 0 });
});
it('accounts for cached_tokens when prompt_tokens_details is present', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 50,
completion_tokens: 20,
prompt_tokens_details: { cached_tokens: 15 },
},
});
await withTestActor(() =>
provider.complete({
model: 'qwen3.6-max-preview',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const model = ALIBABA_MODELS.find(
(m) => m.id === 'qwen3.6-max-preview',
)!;
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 50,
completion_tokens: 20,
cached_tokens: 15,
});
expect(prefix).toBe('alibaba:qwen3.6-max-preview');
expect(overrides).toEqual({
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
completion_tokens: 20 * Number(model.costs.completion_tokens),
cached_tokens: 15 * Number(model.costs.cached_tokens ?? 0),
});
});
});
// ── Streaming deltas ────────────────────────────────────────────────
describe('AlibabaProvider.complete streaming', () => {
it('streams text deltas through to text events and meters final usage', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 4, completion_tokens: 2 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
);
expect((result as { stream: boolean }).stream).toBe(true);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const textEvents = events.filter((e) => e.type === 'text');
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
const usageEvent = events.find((e) => e.type === 'usage');
expect(usageEvent?.usage).toEqual({
prompt_tokens: 4,
completion_tokens: 2,
cached_tokens: 0,
});
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('alibaba:qwen-plus');
expect(overrides).toEqual({
prompt_tokens: 4 * Number(qwenPlus.costs.prompt_tokens),
completion_tokens: 2 * Number(qwenPlus.costs.completion_tokens),
cached_tokens: 0,
});
});
it('builds a tool_use block from streamed function-call deltas', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'call_1',
function: {
name: 'lookup',
arguments: '{"q":',
},
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: { arguments: '"puter"}' },
},
],
},
},
],
},
{
choices: [{ delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
type: 'function',
function: { name: 'lookup', parameters: {} },
},
],
stream: true,
}),
);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const toolEvent = events.find((e) => e.type === 'tool_use');
expect(toolEvent).toBeDefined();
expect(toolEvent?.id).toBe('call_1');
expect(toolEvent?.name).toBe('lookup');
expect(toolEvent?.input).toEqual({ q: 'puter' });
});
});
// ── Error mapping ───────────────────────────────────────────────────
describe('AlibabaProvider.complete error mapping', () => {
it('rethrows errors raised by the OpenAI client unchanged', async () => {
const { provider } = makeProvider();
const apiError = new Error('DashScope exploded');
createMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.complete({
model: 'qwen-plus',
messages: [{ role: 'user', content: 'boom' }],
}),
),
).rejects.toBe(apiError);
expect(recordSpy).not.toHaveBeenCalled();
});
});
// ── Moderation ──────────────────────────────────────────────────────
describe('AlibabaProvider.checkModeration', () => {
it('throws — Alibaba provider does not implement moderation', () => {
const { provider } = makeProvider();
expect(() => provider.checkModeration('anything')).toThrow(
/not implemented/i,
);
});
});
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { ALIBABA_MODELS } from './models.js';
type AlibabaConfig = {
apiKey: string;
apiBaseUrl?: string;
};
export class AlibabaProvider implements IChatProvider {
#openai: OpenAI;
#meteringService: MeteringService;
constructor(config: AlibabaConfig, meteringService: MeteringService) {
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL:
config.apiBaseUrl ??
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
});
this.#meteringService = meteringService;
}
getDefaultModel() {
return 'qwen-plus-latest';
}
models() {
return ALIBABA_MODELS;
}
async list() {
const models = this.models();
const modelNames: string[] = [];
for (const model of models) {
modelNames.push(model.id);
if (model.aliases) {
modelNames.push(...model.aliases);
}
}
return modelNames;
}
async complete({
messages,
stream,
model,
tools,
max_tokens,
temperature,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
messages = await OpenAIUtil.process_input_messages(messages);
const completion = await this.#openai.chat.completions.create({
messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
max_tokens: max_tokens || 1000,
temperature,
stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
} as ChatCompletionCreateParams);
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
const costsOverride = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
`alibaba:${modelUsed.id}`,
costsOverride,
);
return trackedUsage;
},
stream,
completion,
});
}
checkModeration(_text: string) {
throw new Error('Method not implemented.');
}
}
File diff suppressed because it is too large. Load diff
@@ -145,9 +145,9 @@ describe('DeepSeekProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('DeepSeekProvider model catalog', () => {
it('returns deepseek-chat as the default', () => {
it('returns deepseek-v4-flash as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('deepseek-chat');
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
});
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
@@ -164,8 +164,10 @@ describe('DeepSeekProvider model catalog', () => {
expect(ids).toContain(a);
}
}
expect(ids).toContain('deepseek-v4-flash');
expect(ids).toContain('deepseek-v4-pro');
expect(ids).toContain('deepseek-chat');
expect(ids).toContain('deepseek/deepseek-chat');
expect(ids).toContain('deepseek/deepseek-v4-pro');
});
});
@@ -188,13 +190,13 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('deepseek-chat');
expect(args.model).toBe('deepseek-v4-flash');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
@@ -205,7 +207,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
}),
@@ -220,7 +222,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -244,7 +246,7 @@ describe('DeepSeekProvider.complete request shape', () => {
];
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
@@ -258,7 +260,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
@@ -270,7 +272,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -286,7 +288,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{
role: 'assistant',
@@ -327,7 +329,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{
role: 'assistant',
@@ -364,7 +366,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [
{ role: 'user', content: 'do tool call' },
{
@@ -412,16 +414,16 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-reasoner',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-reasoner');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-reasoner',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -432,17 +434,17 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// `deepseek/deepseek-chat` is an alias of `deepseek-chat`.
model: 'deepseek/deepseek-chat',
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
model: 'deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-chat',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -458,11 +460,41 @@ describe('DeepSeekProvider model resolution', () => {
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-chat',
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
// so callers using the old names get transparently upgraded — and
// metered against the v4-flash canonical prefix.
it.each([
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek:deepseek/deepseek-chat',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: alias,
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-v4-flash',
expect.any(Object),
);
});
@@ -489,7 +521,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -504,8 +536,8 @@ describe('DeepSeekProvider.complete non-stream output', () => {
cached_tokens: 10,
});
// deepseek-chat costs: prompt=56, completion=168, cached=0.
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
@@ -514,7 +546,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
cached_tokens: 10,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('deepseek:deepseek-chat');
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(overrides).toEqual({
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
completion_tokens: 50 * Number(chat.costs.completion_tokens),
@@ -549,7 +581,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
@@ -584,7 +616,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -617,7 +649,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -642,10 +674,10 @@ describe('DeepSeekProvider.complete streaming', () => {
cached_tokens: 1,
});
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('deepseek:deepseek-chat');
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(overrides).toEqual({
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
completion_tokens: 2 * Number(chat.costs.completion_tokens),
@@ -698,7 +730,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
@@ -737,7 +769,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -40,7 +40,7 @@ export class DeepSeekProvider implements IChatProvider {
}
getDefaultModel() {
return 'deepseek-chat';
return 'deepseek-v4-flash';
}
models() {
@@ -137,20 +137,18 @@ export class DeepSeekProvider implements IChatProvider {
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
actor!,
`deepseek:${modelUsed.id}`,
costsOverrideFromModel,
);
return trackedUsage;
},
stream,
stream: stream,
completion,
});
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
checkModeration(_text: string) {
throw new Error('Method not implemented.');
}
}
@@ -22,47 +22,56 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const DEEPSEEK_MODELS: IChatModel[] = [
{
puterId: 'deepseek:deepseek/deepseek-chat',
id: 'deepseek-chat',
puterId: 'deepseek:deepseek/deepseek-v4-flash',
id: 'deepseek-v4-flash',
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-12-26',
knowledge: '2026-04',
release_date: '2026-04-24',
name: 'DeepSeek Chat',
aliases: ['deepseek/deepseek-chat'],
context: 128000,
aliases: [
'deepseek-v4-flash',
'deepseek/deepseek-v4-flash',
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek/deepseek-v4-flash',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-chat',
],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 56,
completion_tokens: 168,
cached_tokens: 0,
prompt_tokens: 14,
completion_tokens: 28,
cached_tokens: 0.28,
},
max_tokens: 8000,
max_tokens: 384_000,
},
{
puterId: 'deepseek:deepseek/deepseek-reasoner',
id: 'deepseek-reasoner',
puterId: 'deepseek:deepseek/deepseek-v4-pro',
id: 'deepseek-v4-pro',
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-07',
release_date: '2025-01-20',
name: 'DeepSeek Reasoner',
aliases: ['deepseek/deepseek-reasoner'],
context: 128000,
knowledge: '2026-04',
release_date: '2026-04-24',
name: 'DeepSeek Chat',
aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 56,
completion_tokens: 168,
cached_tokens: 0,
prompt_tokens: 174,
completion_tokens: 348,
cached_tokens: 1.45,
},
max_tokens: 64000,
max_tokens: 384_000,
},
];
@@ -20,7 +20,7 @@
/**
* Integration test for the Mistral provider.
*
* Uses `mistral-small-2506` (provider default, cheapest tier). Skipped
* Uses `mistral-small-2603` (provider default, cheapest tier). Skipped
* when `PUTER_TEST_AI_MISTRAL_API_KEY` is unset.
*/
@@ -47,7 +47,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
@@ -149,9 +149,9 @@ describe('MistralAIProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('MistralAIProvider model catalog', () => {
it('returns mistral-small-2506 as the default', () => {
it('returns the configured small model as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('mistral-small-2506');
expect(provider.getDefaultModel()).toBe('mistral-small-2603');
});
it('exposes the static MISTRAL_MODELS list verbatim from models()', async () => {
@@ -171,7 +171,7 @@ describe('MistralAIProvider model catalog', () => {
}
// Sanity: a known alias is present alongside its canonical id.
expect(ids).toContain('mistral-small-latest');
expect(ids).toContain('mistral-small-2506');
expect(ids).toContain('mistral-small-2603');
});
});
@@ -194,7 +194,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 256,
temperature: 0.4,
@@ -202,7 +202,7 @@ describe('MistralAIProvider.complete request shape', () => {
);
const [args] = completeMock.mock.calls[0]!;
expect(args.model).toBe('mistral-small-2506');
expect(args.model).toBe('mistral-small-2603');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
// Mistral's SDK uses maxTokens (camelCase). The provider should
// adapt our snake_case input.
@@ -216,7 +216,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -240,7 +240,7 @@ describe('MistralAIProvider.complete request shape', () => {
];
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
@@ -257,7 +257,7 @@ describe('MistralAIProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [
{
role: 'assistant',
@@ -308,7 +308,7 @@ describe('MistralAIProvider.complete request shape', () => {
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
@@ -320,7 +320,7 @@ describe('MistralAIProvider.complete request shape', () => {
streamMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -370,17 +370,17 @@ describe('MistralAIProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// `mistral-small-latest` is an alias of `mistral-small-2506`.
// `mistral-small-latest` is an alias of `mistral-small-2603`.
model: 'mistral-small-latest',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'mistral:mistral-small-2506',
'mistral:mistral-small-2603',
expect.any(Object),
);
});
@@ -396,11 +396,11 @@ describe('MistralAIProvider model resolution', () => {
}),
);
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'mistral:mistral-small-2506',
'mistral:mistral-small-2603',
expect.any(Object),
);
});
@@ -424,7 +424,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -450,12 +450,12 @@ describe('MistralAIProvider.complete non-stream output', () => {
cached_tokens: 0,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('mistral:mistral-small-2506');
// mistral-small-2506 costs: prompt=10, completion=30. cached_tokens
expect(prefix).toBe('mistral:mistral-small-2603');
// mistral-small-2603 costs: prompt=15, completion=60. cached_tokens
// is undefined in the model row → multiplied by 0 → NaN-safe 0.
expect(overrides).toMatchObject({
prompt_tokens: 100 * 10,
completion_tokens: 50 * 30,
prompt_tokens: 100 * 15,
completion_tokens: 50 * 60,
});
});
@@ -486,7 +486,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
@@ -535,7 +535,7 @@ describe('MistralAIProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -560,14 +560,14 @@ describe('MistralAIProvider.complete streaming', () => {
cached_tokens: 0,
});
// mistral-small-2506: prompt=10, completion=30.
// mistral-small-2603: prompt=15, completion=60.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(prefix).toBe('mistral:mistral-small-2506');
expect(prefix).toBe('mistral:mistral-small-2603');
expect(overrides).toMatchObject({
prompt_tokens: 4 * 10,
completion_tokens: 2 * 30,
prompt_tokens: 4 * 15,
completion_tokens: 2 * 60,
});
});
@@ -626,7 +626,7 @@ describe('MistralAIProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
@@ -666,7 +666,7 @@ describe('MistralAIProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'mistral-small-2506',
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -21,12 +21,12 @@ import { Mistral } from '@mistralai/mistralai';
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import type {
IChatCompleteResult,
IChatProvider,
ICompleteArguments,
IChatCompleteResult,
} from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { MISTRAL_MODELS } from './models.js';
export class MistralAIProvider implements IChatProvider {
@@ -42,7 +42,7 @@ export class MistralAIProvider implements IChatProvider {
}
getDefaultModel() {
return 'mistral-small-2506';
return 'mistral-small-2603';
}
async models() {
@@ -19,8 +19,37 @@
import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
// Hardcoded from https://models.dev/api.json and https://docs.mistral.ai/models/overview
export const MISTRAL_MODELS: IChatModel[] = [
{
puterId: 'mistralai:mistralai/mistral-medium-3-5',
id: 'mistral-medium-3-5',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2026-04',
release_date: '2026-04-29',
name: 'Mistral Medium 3.5',
aliases: [
'mistral-medium-3',
'mistral-medium-2604',
'mistralai/mistral-medium-3-5',
'mistralai/mistral-medium-2604',
],
context: 262_144,
max_tokens: 262_144,
description:
'State-of-the-art multimodal model optimized for agentic and coding tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 150,
completion_tokens: 750,
},
},
{
puterId: 'mistralai:mistralai/mistral-medium-2508',
id: 'mistral-medium-2508',
@@ -29,106 +58,82 @@ export const MISTRAL_MODELS: IChatModel[] = [
tool_call: true,
knowledge: '2025-05',
release_date: '2025-08-12',
name: 'mistral-medium-2508',
name: 'Mistral Medium 3.1',
aliases: [
'mistral-medium-latest',
'mistral-medium',
'mistralai/mistral-medium-2508',
'mistral-medium-2508',
'mistral-medium-latest',
'mistralai/mistral-medium-latest',
],
context: 131072,
max_tokens: 131072,
description: 'Update on Mistral Medium 3 with improved capabilities.',
context: 262_144,
max_tokens: 262_144,
description: 'Frontier-class multimodal model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 40,
completion_tokens: 200,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-7b',
id: 'open-mistral-7b',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2023-12',
release_date: '2023-09-27',
name: 'open-mistral-7b',
aliases: [
'mistral-tiny',
'mistral-tiny-2312',
'mistralai/open-mistral-7b',
],
context: 32768,
max_tokens: 32768,
description: 'Our first dense model released September 2023.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 25,
completion_tokens: 25,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-nemo',
id: 'open-mistral-nemo',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-07-01',
name: 'open-mistral-nemo',
aliases: [
'open-mistral-nemo-2407',
'mistral-tiny-2407',
'mistral-tiny-latest',
'mistralai/open-mistral-nemo',
],
context: 131072,
max_tokens: 131072,
description:
'Our best multilingual open source model released July 2024.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/pixtral-large-2411',
id: 'pixtral-large-2411',
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-11',
release_date: '2024-11-01',
name: 'pixtral-large-2411',
knowledge: '2025-12',
release_date: '2025-12-02',
name: 'Mistral Large 3',
aliases: [
'pixtral-large-latest',
'mistral-large-pixtral-2411',
'mistralai/pixtral-large-2411',
'mistral-large-latest',
'mistral-large',
'mistralai/mistral-large-latest',
'mistralai/mistral-large-2512',
],
context: 131072,
max_tokens: 131072,
description: 'Official pixtral-large-2411 Mistral AI model',
context: 262_144,
max_tokens: 262_144,
description:
'State-of-the-art, open-weight, general-purpose multimodal model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 200,
completion_tokens: 600,
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/mistral-small-2603',
id: 'mistral-small-2603',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-06',
release_date: '2026-03-16',
name: 'Mistral Small 4',
aliases: [
'mistral-small-latest',
'mistral-small',
'mistralai/mistral-small-latest',
'mistralai/mistral-small-2603',
],
context: 256_000,
max_tokens: 256_000,
description:
'Hybrid open-weight model for instruct, reasoning, and coding tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
completion_tokens: 60,
},
},
{
@@ -137,116 +142,73 @@ export const MISTRAL_MODELS: IChatModel[] = [
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-05-29',
name: 'codestral-2508',
knowledge: '2025-08',
release_date: '2025-08-29',
name: 'Codestral',
aliases: ['codestral-latest', 'mistralai/codestral-2508'],
context: 256000,
max_tokens: 256000,
context: 256_000,
max_tokens: 256_000,
description:
'Our cutting-edge language model for coding released August 2025.',
'Cutting-edge language model for code completion, proficient in over 80 programming languages.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 30,
completion_tokens: 90,
},
},
{
puterId: 'mistralai:mistralai/devstral-small-2507',
id: 'devstral-small-2507',
puterId: 'mistralai:mistralai/devstral-2512',
id: 'devstral-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-05',
release_date: '2025-07-10',
name: 'devstral-small-2507',
aliases: ['devstral-small-latest', 'mistralai/devstral-small-2507'],
context: 131072,
max_tokens: 131072,
description: 'Our small open-source code-agentic model.',
knowledge: '2025-12',
release_date: '2025-12-09',
name: 'Devstral 2',
aliases: [
'devstral-latest',
'mistralai/devstral-latest',
'mistralai/devstral-2512',
'devstral-medium-latest',
'mistralai/devstral-medium-latest',
],
context: 262_144,
max_tokens: 262_144,
description:
'Frontier open-source code agents model for software engineering.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
cached_tokens: 0,
},
},
{
puterId: 'mistralai:mistralai/devstral-medium-2507',
id: 'devstral-medium-2507',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-05',
release_date: '2025-07-10',
name: 'devstral-medium-2507',
aliases: ['devstral-medium-latest', 'mistralai/devstral-medium-2507'],
context: 131072,
max_tokens: 131072,
description: 'Our medium code-agentic model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 40,
completion_tokens: 200,
cached_tokens: 0,
},
},
{
puterId: 'mistralai:mistralai/mistral-small-2506',
id: 'mistral-small-2506',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-03',
release_date: '2025-06-20',
name: 'mistral-small-2506',
aliases: ['mistral-small-latest', 'mistralai/mistral-small-2506'],
context: 131072,
max_tokens: 131072,
description:
'Our latest enterprise-grade small model with the latest version released June 2025.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
{
puterId: 'mistralai:mistralai/magistral-medium-2509',
id: 'magistral-medium-2509',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
open_weights: false,
tool_call: true,
knowledge: '2025-06',
release_date: '2025-03-17',
name: 'magistral-medium-2509',
release_date: '2025-09-01',
name: 'Magistral Medium 1.2',
aliases: ['magistral-medium-latest', 'mistralai/magistral-medium-2509'],
context: 131072,
max_tokens: 131072,
description:
'Our frontier-class reasoning model release candidate September 2025.',
context: 131_072,
max_tokens: 131_072,
description: 'Frontier-class multimodal reasoning model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 500,
},
@@ -258,143 +220,149 @@ export const MISTRAL_MODELS: IChatModel[] = [
open_weights: true,
tool_call: true,
knowledge: '2025-06',
release_date: '2025-03-17',
name: 'magistral-small-2509',
release_date: '2025-09-01',
name: 'Magistral Small 1.2',
aliases: ['magistral-small-latest', 'mistralai/magistral-small-2509'],
context: 131072,
max_tokens: 131072,
description: 'Our efficient reasoning model released September 2025.',
context: 131_072,
max_tokens: 131_072,
description: 'Efficient open-weight reasoning model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/voxtral-mini-2507',
id: 'voxtral-mini-2507',
name: 'voxtral-mini-2507',
aliases: ['voxtral-mini-latest', 'mistralai/voxtral-mini-2507'],
context: 32768,
max_tokens: 32768,
description: 'A mini audio understanding model released in July 2025',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 4,
completion_tokens: 4,
},
},
{
puterId: 'mistralai:mistralai/voxtral-small-2507',
id: 'voxtral-small-2507',
name: 'voxtral-small-2507',
aliases: ['voxtral-small-latest', 'mistralai/voxtral-small-2507'],
context: 32768,
max_tokens: 32768,
description: 'A small audio understanding model released in July 2025',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
{
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-latest',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-11',
release_date: '2024-11-01',
name: 'mistral-large-2512',
aliases: ['mistral-large-2512', 'mistralai/mistral-large-2512'],
context: 262144,
max_tokens: 262144,
description: 'Official mistral-large-2512 Mistral AI model',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/ministral-3b-2512',
id: 'ministral-3b-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-10-01',
name: 'ministral-3b-2512',
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
context: 131072,
max_tokens: 131072,
description: 'Ministral 3 (a.k.a. Tinystral) 3B Instruct.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 10,
completion_tokens: 10,
},
},
{
puterId: 'mistralai:mistralai/ministral-8b-2512',
id: 'ministral-8b-2512',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-10',
release_date: '2024-10-01',
name: 'ministral-8b-2512',
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
context: 262144,
max_tokens: 262144,
description: 'Ministral 3 (a.k.a. Tinystral) 8B Instruct.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/ministral-14b-2512',
id: 'ministral-14b-2512',
name: 'ministral-14b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 14B',
aliases: ['ministral-14b-latest', 'mistralai/ministral-14b-2512'],
context: 262144,
max_tokens: 262144,
description: 'Ministral 3 (a.k.a. Tinystral) 14B Instruct.',
context: 262_144,
max_tokens: 262_144,
description:
'Best-in-class compact model with text and vision capabilities.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1000000,
tokens: 1_000_000,
prompt_tokens: 20,
completion_tokens: 20,
},
},
{
puterId: 'mistralai:mistralai/ministral-8b-2512',
id: 'ministral-8b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 8B',
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
context: 262_144,
max_tokens: 262_144,
description:
'Efficient compact model with text and vision capabilities.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 10,
},
},
{
puterId: 'mistralai:mistralai/ministral-3b-2512',
id: 'ministral-3b-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-16',
name: 'Ministral 3 3B',
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
context: 131_072,
max_tokens: 131_072,
description: 'Tiny, efficient model with multimodal support.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 4,
completion_tokens: 4,
},
},
{
puterId: 'mistralai:mistralai/open-mistral-nemo-2407',
id: 'open-mistral-nemo-2407',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2024-07',
release_date: '2024-07-25',
name: 'Mistral Nemo 12B',
aliases: [
'open-mistral-nemo',
'mistral-nemo',
'mistral-nemo-latest',
'mistralai/open-mistral-nemo',
'mistralai/open-mistral-nemo-2407',
'mistralai/mistral-nemo',
],
context: 128_000,
max_tokens: 128_000,
description: 'Best multilingual open source model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
completion_tokens: 15,
},
},
{
puterId: 'mistralai:mistralai/voxtral-small-2507',
id: 'voxtral-small-2507',
modalities: { input: ['text', 'audio'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-07',
release_date: '2025-07-15',
name: 'Voxtral Small',
aliases: [
'voxtral-small-latest',
'mistralai/voxtral-small-2507',
'mistralai/voxtral-small-latest',
'voxtral-small-latest',
],
context: 32_768,
max_tokens: 32_768,
description: 'Audio-input model for instruct tasks.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
completion_tokens: 30,
},
},
];
@@ -87,6 +87,69 @@ export const OPEN_AI_MODELS: IChatModel[] = [
context: 1_050_000,
max_tokens: 1_050_000,
},
{
puterId: 'openai:openai/gpt-5.4-pro',
id: 'gpt-5.4-pro',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-05',
aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 3000,
completion_tokens: 18000,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.4-mini',
id: 'gpt-5.4-mini',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 75,
cached_tokens: 7.5,
completion_tokens: 450,
},
context: 400_000,
max_tokens: 128_000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5.4-nano',
id: 'gpt-5.4-nano',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-19',
aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
cached_tokens: 2,
completion_tokens: 125,
},
context: 400_000,
max_tokens: 128_000,
},
{
puterId: 'openai:openai/gpt-5.3-codex',
id: 'gpt-5.3-codex',
@@ -384,20 +384,10 @@ export const create_chat_stream_handler_responses_api =
chatStream.end(usage);
};
/**
*
* @param {object} params
* @param {(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown } params.usage_calculator
* @returns
*/
export const handle_completion_output = async ({
deviations,
stream,
completion,
moderate,
usage_calculator,
finally_fn,
}) => {
export const handle_completion_output = async (
/** @type {Record<string,unknown> & {usage_calculator:(args: {usage: import("openai/resources/completions.mjs").CompletionUsage})=> unknown }}*/
{ deviations, stream, completion, moderate, usage_calculator, finally_fn },
) => {
deviations = Object.assign(
{
// affected by: Mistral
@@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => {
});
});
// ── puter_output_path ─────────────────────────────────────────────
describe('ImageGenerationDriver.generate puter_output_path', () => {
const TEST_ACTOR: import('../../core/actor.js').Actor = {
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
};
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
it('throws 400 when puter_output_path resolves to root', async () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('throws 400 when puter_output_path parent is root (e.g. /image.png)', async () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/image.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('throws 403 when ACL denies write access to the destination', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(false);
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/somedir/image.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it('ACL check runs BEFORE provider.generate so credits are not wasted on a denied path', async () => {
const callOrder: string[] = [];
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockImplementation(async () => {
callOrder.push('acl');
return false;
});
openaiImagesGenerateMock.mockImplementation(async () => {
callOrder.push('provider');
return { data: [{ url: 'https://oai/img.png' }] };
});
await expect(
withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(callOrder).toEqual(['acl']);
});
it('resolves ~ in puter_output_path to /<username>/', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '~/images/out.png',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
).toBe('/testuser/images/out.png');
});
it('writes the generated image to FS and still returns the result URL', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
const result = await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/photos/out.png',
} as never),
);
expect(result).toBe('https://oai/img.png');
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(userId).toBe(42);
const meta = (
writeArg as {
fileMetadata: {
path: string;
contentType: string;
overwrite: boolean;
};
}
).fileMetadata;
expect(meta.path).toBe('/testuser/photos/out.png');
expect(meta.contentType).toBe('image/png');
expect(meta.overwrite).toBe(true);
});
it('does not forward puter_output_path to the upstream provider call', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
fetchSpy.mockResolvedValueOnce(
new Response(Buffer.from('fake-png'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
await withTestUser(() =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
);
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
expect(sent.puter_output_path).toBeUndefined();
});
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
const noIdActor: import('../../core/actor.js').Actor = {
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
};
await expect(
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
model: 'dall-e-2',
prompt: 'hi',
puter_output_path: '/noone/dir/img.png',
} as never),
),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
});
// Avoid coupling the 'unused' XAI export to lint. The catalog reference
// is also used implicitly by the routing tests above.
void XAI_IMAGE_GENERATION_MODELS;
@@ -18,8 +18,12 @@
*/
import crypto from 'node:crypto';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
@@ -114,12 +118,34 @@ export class ImageGenerationDriver extends PuterDriver {
}
async generate(args: IGenerateParams): Promise<string> {
const actor = Context.get('actor');
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;
// Validate the output path early — before spending credits.
let resolvedOutputPath: string | undefined;
if (puterOutputPath) {
const username = actor.user?.username;
const userId = actor.user?.id;
if (!userId || !username) {
throw new HttpError(
400,
'User ID required for puter_output_path',
{ legacyCode: 'bad_request' },
);
}
resolvedOutputPath = this.#resolveOutputPath(
puterOutputPath,
username,
);
await this.#assertWriteAccess(actor, resolvedOutputPath);
}
let modelId = args.model?.trim().toLowerCase();
let intendedProvider =
args.provider ?? (Context.get('driverName') as string | undefined);
@@ -172,11 +198,17 @@ export class ImageGenerationDriver extends PuterDriver {
{},
);
return provider.generate({
const result = await provider.generate({
...args,
model: model.id,
provider: model.provider,
});
if (resolvedOutputPath) {
await this.#saveToFS(actor, result, resolvedOutputPath);
}
return result;
}
#normalizeRatio(parameters: IGenerateParams) {
@@ -340,6 +372,106 @@ export class ImageGenerationDriver extends PuterDriver {
}
}
async #saveToFS(
actor: Actor,
result: string,
resolvedPath: string,
): Promise<void> {
const userId = actor.user!.id!;
let buffer: Buffer;
let contentType: string;
if (result.startsWith('data:')) {
const commaIdx = result.indexOf(',');
const header = result.substring(0, commaIdx);
contentType =
header.match(/data:(.*?);/)?.[1] ?? 'application/octet-stream';
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
} else {
const response = await fetch(result);
if (!response.ok) {
throw new HttpError(
502,
`Failed to fetch generated image for FS write: ${response.status}`,
{ legacyCode: 'internal_error' },
);
}
contentType =
response.headers.get('content-type') ??
'application/octet-stream';
buffer = Buffer.from(await response.arrayBuffer());
}
await this.services.fs.write(userId, {
fileMetadata: {
path: resolvedPath,
size: buffer.length,
contentType,
overwrite: true,
createMissingParents: true,
},
fileContent: Readable.from(buffer),
});
}
#resolveOutputPath(outputPath: string, username: string): string {
let resolved = outputPath.trim();
if (resolved === '~' || resolved.startsWith('~/')) {
resolved = `/${username}${resolved.slice(1)}`;
}
assertNormalized(resolved);
if (!resolved.startsWith('/')) {
resolved = `/${resolved}`;
}
if (resolved.length > 1 && resolved.endsWith('/')) {
resolved = resolved.slice(0, -1);
}
return resolved;
}
async #assertWriteAccess(
actor: Actor,
resolvedPath: string,
): Promise<void> {
if (resolvedPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const parentPath = pathPosix.dirname(resolvedPath);
if (parentPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const pathToCheck = parentPath;
const fsService = this.services.fs;
let ancestorsCache: Promise<
Array<{ uid: string; path: string }>
> | null = null;
const canWrite = await this.services.acl.check(
actor,
{
path: pathToCheck,
resolveAncestors() {
if (!ancestorsCache) {
ancestorsCache =
fsService.getAncestorChain(pathToCheck);
}
return ancestorsCache;
},
},
'write',
);
if (!canWrite) {
throw new HttpError(403, 'Write access denied for destination', {
legacyCode: 'access_denied',
});
}
}
#resolveModel(modelId: string, provider?: string): IImageModel | null {
const models = this.#modelIdMap[modelId];
if (!models || models.length === 0) return null;
+1
View File
@@ -63,6 +63,7 @@ export interface IGenerateParams {
input_image?: string;
input_image_mime_type?: string;
input_images?: string[];
puter_output_path?: string;
[key: string]: unknown;
}
@@ -213,7 +213,7 @@ export class SpeechToTextDriver extends PuterDriver {
this.services.fs,
actor,
args.file,
{ maxBytes: MAX_AUDIO_FILE_SIZE },
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
);
const selectedModel =
@@ -113,7 +113,7 @@ describe('XAITTSProvider catalog', () => {
expect(engines[0]).toMatchObject({
id: 'xai-tts',
provider: 'xai',
pricing_per_million_chars: 420,
pricing_per_million_chars: 1500,
});
});
});
@@ -79,7 +79,7 @@ export class XAITTSProvider extends TTSProvider {
id: 'xai-tts',
name: 'xAI TTS',
provider: 'xai',
pricing_per_million_chars: 420,
pricing_per_million_chars: 1500,
},
];
}
@@ -17,18 +17,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// xAI TTS pricing: $4.20 per 1M characters
// = 420 cents per 1M characters
// = 0.00042 cents per character
// = 0.42 microcents per character
// We store microcents per character (cents * 1_000_000 / 1_000_000 chars).
// $4.20 / 1M chars = 420 cents / 1M chars = 0.00042 cents/char = 0.42 ucents/char
// But metering uses integer microcents, so we round up to 1 microcent per char
// to avoid undercharging. More precisely: 420 * 1_000_000 / 1_000_000 = 420 ucents per char? No.
// Let's be precise:
// $4.20 per 1M chars = 420 cents per 1M chars
// In microcents: 420 * 1_000_000 = 420_000_000 microcents per 1M chars
// Per character: 420_000_000 / 1_000_000 = 420 microcents per character
// xAI TTS pricing (per xAI docs, Voice pricing table): $15.00 per 1M characters
// $15.00 per 1M chars = 1500 cents per 1M chars
// In microcents: 1500 * 1_000_000 = 1_500_000_000 microcents per 1M chars
// Per character: 1_500_000_000 / 1_000_000 = 1500 microcents per character
export const XAI_TTS_COSTS: Record<string, number> = {
'xai-tts': 420,
'xai-tts': 1500,
};
@@ -448,3 +448,186 @@ describe('VideoGenerationDriver metering propagation', () => {
expect(usageType).toMatch(/^openai:sora-2:/);
});
});
// ── puter_output_path ─────────────────────────────────────────────
describe('VideoGenerationDriver.generate puter_output_path', () => {
const TEST_ACTOR: import('../../core/actor.js').Actor = {
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
};
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
it('throws 400 when puter_output_path is root', async () => {
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('throws 400 when puter_output_path parent is root (e.g. /video.mp4)', async () => {
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/video.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('throws 403 when ACL denies write access', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(false);
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
it('ACL check runs BEFORE provider.generate so credits are not wasted', async () => {
const callOrder: string[] = [];
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockImplementation(async () => {
callOrder.push('acl');
return false;
});
openaiVideosCreateMock.mockImplementation(async () => {
callOrder.push('provider');
return openaiCompletedJob();
});
await expect(
withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(callOrder).toEqual(['acl']);
});
it('resolves ~ in puter_output_path to /<username>/', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '~/videos/clip.mp4',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
).toBe('/testuser/videos/clip.mp4');
});
it('writes stream result to FS and returns a new stream to caller', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
const result = await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
);
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
expect(userId).toBe(42);
const meta = (
writeArg as {
fileMetadata: {
path: string;
contentType: string;
overwrite: boolean;
};
}
).fileMetadata;
expect(meta.path).toBe('/testuser/videos/clip.mp4');
expect(meta.overwrite).toBe(true);
expect(result).toBeDefined();
});
it('does not forward puter_output_path to the upstream provider call', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
fsWriteSpy.mockResolvedValueOnce(undefined as never);
openaiVideosCreateMock.mockResolvedValueOnce(openaiCompletedJob());
openaiVideosDownloadMock.mockResolvedValueOnce(openaiDownload());
await withTestUser(() =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/testuser/dir/clip.mp4',
} as never),
);
const sent = openaiVideosCreateMock.mock.calls[0]![0];
expect(sent.puter_output_path).toBeUndefined();
});
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
const noIdActor: import('../../core/actor.js').Actor = {
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
};
await expect(
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
prompt: 'hi',
model: 'sora-2',
puter_output_path: '/noone/dir/clip.mp4',
} as never),
),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(openaiVideosCreateMock).not.toHaveBeenCalled();
});
});
@@ -17,8 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
@@ -114,12 +118,34 @@ export class VideoGenerationDriver extends PuterDriver {
}
async generate(args: IGenerateVideoParams) {
const actor = Context.get('actor');
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;
// Validate the output path early — before spending credits.
let resolvedOutputPath: string | undefined;
if (puterOutputPath) {
const username = actor.user?.username;
const userId = actor.user?.id;
if (!userId || !username) {
throw new HttpError(
400,
'User ID required for puter_output_path',
{ legacyCode: 'bad_request' },
);
}
resolvedOutputPath = this.#resolveOutputPath(
puterOutputPath,
username,
);
await this.#assertWriteAccess(actor, resolvedOutputPath);
}
if (args.model) {
args.model = args.model.trim().toLowerCase();
}
@@ -202,11 +228,17 @@ export class VideoGenerationDriver extends PuterDriver {
args.resolution = normalizedResolution;
}
return await provider.generate({
const result = await provider.generate({
...args,
model: model.id,
provider: model.provider,
});
if (resolvedOutputPath) {
return await this.#saveToFS(actor, result, resolvedOutputPath);
}
return result;
}
// -- Provider registration -----------------------------------------------
@@ -350,6 +382,135 @@ export class VideoGenerationDriver extends PuterDriver {
}
}
async #saveToFS(
actor: Actor,
result: unknown,
resolvedPath: string,
): Promise<unknown> {
const userId = actor.user!.id!;
let buffer: Buffer;
let contentType: string;
if (typeof result === 'string') {
if (result.startsWith('data:')) {
const commaIdx = result.indexOf(',');
const header = result.substring(0, commaIdx);
contentType = header.match(/data:(.*?);/)?.[1] ?? 'video/mp4';
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
} else {
const response = await fetch(result);
if (!response.ok) {
throw new HttpError(
502,
`Failed to fetch generated video for FS write: ${response.status}`,
{ legacyCode: 'internal_error' },
);
}
contentType =
response.headers.get('content-type') ?? 'video/mp4';
buffer = Buffer.from(await response.arrayBuffer());
}
} else if (result && typeof result === 'object' && 'stream' in result) {
const streamResult = result as {
stream: Readable;
content_type: string;
};
contentType = streamResult.content_type || 'video/mp4';
const chunks: Buffer[] = [];
for await (const chunk of streamResult.stream) {
chunks.push(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
);
}
buffer = Buffer.concat(chunks);
} else {
throw new HttpError(
500,
'Unsupported video result format for puter_output_path',
{ legacyCode: 'internal_error' },
);
}
await this.services.fs.write(userId, {
fileMetadata: {
path: resolvedPath,
size: buffer.length,
contentType,
overwrite: true,
createMissingParents: true,
},
fileContent: Readable.from(buffer),
});
// For stream results, reconstruct a new stream from the buffered data
if (typeof result !== 'string') {
return {
stream: Readable.from(buffer),
content_type: contentType,
};
}
return result;
}
#resolveOutputPath(outputPath: string, username: string): string {
let resolved = outputPath.trim();
if (resolved === '~' || resolved.startsWith('~/')) {
resolved = `/${username}${resolved.slice(1)}`;
}
assertNormalized(resolved);
if (!resolved.startsWith('/')) {
resolved = `/${resolved}`;
}
if (resolved.length > 1 && resolved.endsWith('/')) {
resolved = resolved.slice(0, -1);
}
return resolved;
}
async #assertWriteAccess(
actor: Actor,
resolvedPath: string,
): Promise<void> {
if (resolvedPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const parentPath = pathPosix.dirname(resolvedPath);
if (parentPath === '/') {
throw new HttpError(400, 'Cannot write to root path', {
legacyCode: 'cannot_write_to_root',
});
}
const pathToCheck = parentPath;
const fsService = this.services.fs;
let ancestorsCache: Promise<
Array<{ uid: string; path: string }>
> | null = null;
const canWrite = await this.services.acl.check(
actor,
{
path: pathToCheck,
resolveAncestors() {
if (!ancestorsCache) {
ancestorsCache =
fsService.getAncestorChain(pathToCheck);
}
return ancestorsCache;
},
},
'write',
);
if (!canWrite) {
throw new HttpError(403, 'Write access denied for destination', {
legacyCode: 'access_denied',
});
}
}
#resolveModel(modelId: string, provider?: string): IVideoModel | null {
const models = this.#modelIdMap[modelId?.trim().toLowerCase()];
if (!models || models.length === 0) return null;
+1
View File
@@ -68,6 +68,7 @@ export interface IGenerateVideoParams {
metadata?: object;
input_reference?: unknown;
no_extra_params?: boolean;
puter_output_path?: string;
}
export interface IVideoProvider {
+8 -8
View File
@@ -64,13 +64,13 @@ const meta = (
describe('KVStoreDriver — rate-limit policy', () => {
const m = meta(new KVStoreDriver(...fake()));
it('pins the kv tier values (registered 200 / 10s, temp 100 / 10s)', () => {
it('pins the kv tier values (registered 400 / 10s, temp 200 / 10s)', () => {
expect(m.rateLimit?.default).toEqual({
limit: 200,
limit: 400,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
[DEFAULT_FREE_SUBSCRIPTION]: 400,
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
},
});
});
@@ -98,13 +98,13 @@ describe('AppDriver — rate-limit policy', () => {
describe('SubdomainDriver — rate-limit policy', () => {
const m = meta(new SubdomainDriver(...fake()));
it('matches AppDriver tier values (subdomain CRUD is similarly cheap)', () => {
it('pins the subdomain tier values (registered 200 / 10s, temp 100 / 10s)', () => {
expect(m.rateLimit?.default).toEqual({
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 100,
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
},
});
});
+3 -3
View File
@@ -51,11 +51,11 @@ export class KVStoreDriver extends PuterDriver {
// (no subscription resolution) is not given the tighter cap.
readonly rateLimit: DriverRateLimitConfig = {
default: {
limit: 200,
limit: 400,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
[DEFAULT_FREE_SUBSCRIPTION]: 400,
[DEFAULT_TEMP_SUBSCRIPTION]: 200,
},
},
};
@@ -81,11 +81,11 @@ export class SubdomainDriver extends PuterDriver {
// same shape — the three crud-q drivers share one envelope.
readonly rateLimit: DriverRateLimitConfig = {
default: {
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 100,
[DEFAULT_TEMP_SUBSCRIPTION]: 50,
[DEFAULT_FREE_SUBSCRIPTION]: 200,
[DEFAULT_TEMP_SUBSCRIPTION]: 100,
},
},
};
+6 -6
View File
@@ -37,11 +37,11 @@ import { AI_CONCURRENT, AI_RATE_LIMIT } from './aiLimits.js';
describe('AI_RATE_LIMIT', () => {
it('pins the documented tier values', () => {
expect(AI_RATE_LIMIT.default).toEqual({
limit: 100,
limit: 200,
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 30,
[DEFAULT_TEMP_SUBSCRIPTION]: 20,
[DEFAULT_FREE_SUBSCRIPTION]: 60,
[DEFAULT_TEMP_SUBSCRIPTION]: 40,
},
});
});
@@ -75,10 +75,10 @@ describe('AI_RATE_LIMIT', () => {
describe('AI_CONCURRENT', () => {
it('pins the documented tier values', () => {
expect(AI_CONCURRENT.default).toEqual({
limit: 10,
limit: 20,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 3,
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
[DEFAULT_FREE_SUBSCRIPTION]: 6,
[DEFAULT_TEMP_SUBSCRIPTION]: 4,
},
});
});
+6 -6
View File
@@ -42,21 +42,21 @@ import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
export const AI_RATE_LIMIT: DriverRateLimitConfig = {
default: {
limit: 100, // subscribed / paid tier
limit: 200, // subscribed / paid tier
window: 10_000,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 30, // verified registered user
[DEFAULT_TEMP_SUBSCRIPTION]: 20, // temp / anonymous-email user
[DEFAULT_FREE_SUBSCRIPTION]: 60, // verified registered user
[DEFAULT_TEMP_SUBSCRIPTION]: 40, // temp / anonymous-email user
},
},
};
export const AI_CONCURRENT: DriverConcurrentConfig = {
default: {
limit: 10, // subscribed / paid tier
limit: 20, // subscribed / paid tier
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: 3, // verified registered user
[DEFAULT_TEMP_SUBSCRIPTION]: 2, // temp / anonymous-email user
[DEFAULT_FREE_SUBSCRIPTION]: 6, // verified registered user
[DEFAULT_TEMP_SUBSCRIPTION]: 4, // temp / anonymous-email user
},
},
};
+18 -12
View File
@@ -37,6 +37,7 @@ import {
allowedAppIdsGate,
requireAuthGate,
requireEmailConfirmedGate,
requireNonAccessTokenGate,
requireUserActorGate,
requireVerifiedGate,
subdomainGate,
@@ -368,13 +369,10 @@ export class PuterServer {
* `#materializeRoute` as those options ship.
*/
#installGlobalMiddleware() {
// ── Cookie parsing ──────────────────────────────────────────
this.#app.use(cookieParser());
// ── Compression ─────────────────────────────────────────────
this.#app.use(compression());
// ── Security headers (helmet) ───────────────────────────────
this.#app.use(helmet.noSniff());
this.#app.use(helmet.hsts());
this.#app.use(helmet.ieNoOpen());
@@ -615,7 +613,6 @@ export class PuterServer {
this.#app.use((req, res, next) => {
const origin = req.headers.origin;
const subdomain = req.subdomains?.[req.subdomains.length - 1];
const isApiOrDav = subdomain === 'api' || subdomain === 'dav';
// Allow any origin. puter.js is meant to be consumed from
// arbitrary third-party sites, so reflect the caller's origin
@@ -623,11 +620,11 @@ export class PuterServer {
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
if (origin) res.vary('Origin');
// Credentials require a specific (non-`*`) Allow-Origin, which
// we just set when an origin was present. Enable on API/DAV
// so cookie-auth works cross-origin.
if (isApiOrDav && origin) {
// Sticky cookies require api to allow credentials, but only for the API subdomain, and be careful not to set any other credentials on it
if (subdomain === 'api' && origin) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
} else if (subdomain === 'dav') {
res.setHeader('Access-Control-Allow-Credentials', 'false');
}
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
@@ -795,7 +792,9 @@ export class PuterServer {
opts.allowedAppIds ||
opts.requireVerified,
);
if (needsAuth) mwChain.push(requireAuthGate());
if (needsAuth) {
mwChain.push(requireAuthGate());
}
// Default-on email confirmation gate. Every authenticated route
// rejects users pending confirmation unless `allowUnconfirmed`
@@ -806,6 +805,11 @@ export class PuterServer {
mwChain.push(requireEmailConfirmedGate());
}
// block access tokens by default
if (needsAuth && !opts.allowAccessToken) {
mwChain.push(requireNonAccessTokenGate());
}
// `requireVerified` intentionally does NOT imply `requireUserActor`:
// FS routes (and similar) want the user's email to be confirmed even
// when an app acts on the user's behalf. `requireVerifiedGate` reads
@@ -817,7 +821,9 @@ export class PuterServer {
// token, not only from browser sessions. `adminOnlyGate` gates on
// `actor.user.username`, which is populated for access-token and
// app-under-user actors alike.
if (opts.requireUserActor) mwChain.push(requireUserActorGate());
if (opts.requireUserActor) {
mwChain.push(requireUserActorGate());
}
if (opts.adminOnly) {
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
@@ -942,9 +948,9 @@ export class PuterServer {
};
}
if (fullPath !== undefined) {
app.use(fullPath as any, ...mwChain, handler);
app.use(fullPath as any, ...mwChain.flat(), handler);
} else {
app.use(...mwChain, handler);
app.use(...mwChain.flat(), handler);
}
return;
}
+1 -2
View File
@@ -112,6 +112,7 @@ export class ACLService extends PuterService {
if (resource.path === '/') {
return (PUBLIC_READ_MODES as AclMode[]).includes(mode);
}
const ancestors = await resource.resolveAncestors();
const components = resource.path.slice(1).split('/');
@@ -171,7 +172,6 @@ export class ACLService extends PuterService {
const authorizer = actor.accessToken.issuer;
if (!(await this.check(authorizer, resource, mode))) return false;
const ancestors = await resource.resolveAncestors();
for (const ancestor of ancestors) {
const permissions =
mode === MANAGE_PERM_PREFIX
@@ -219,7 +219,6 @@ export class ACLService extends PuterService {
// Fall back to the permission scan: walk ancestors, any hit wins.
// Widen the scan to all "higher" modes (`write` covers `read`/`list`/
// `see`, etc.) so granting a stronger mode implies the weaker ones.
const ancestors = await resource.resolveAncestors();
for (const ancestor of ancestors) {
const permissions =
mode === MANAGE_PERM_PREFIX
+5 -3
View File
@@ -911,10 +911,12 @@ export class AuthService extends PuterService {
// ── Actor builders ──────────────────────────────────────────────
#actorUserFromRow(user: UserRow) {
// Strip the password hash; pass everything else through so callers
// can read metadata, desktop_bg_*, otp_enabled, etc. without
// re-fetching the user. Mirrors what /whoami exposes off of UserRow.
const { password: _password, ...rest } = user;
return {
uuid: user.uuid,
id: user.id,
username: user.username,
...rest,
email: user.email ?? null,
suspended: user.suspended ?? false,
email_confirmed: user.email_confirmed ?? false,
+3 -1
View File
@@ -18,6 +18,7 @@
*/
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from './resolveNode.js';
import { createHash } from 'node:crypto';
import { Readable, Transform } from 'node:stream';
import type { TransformCallback } from 'node:stream';
@@ -268,7 +269,8 @@ export class FSService extends PuterService {
);
}
let normalizedPath = pathPosix.normalize(trimmedPath);
assertNormalized(trimmedPath);
let normalizedPath = trimmedPath;
if (!normalizedPath.startsWith('/')) {
normalizedPath = `/${normalizedPath}`;
}
+11 -1
View File
@@ -130,6 +130,15 @@ export function splitParentAndName(absolutePath: string): {
return { parentPath: parentPath === '.' ? '/' : parentPath, name };
}
export function assertNormalized(input: string): string {
if (pathPosix.normalize(input) !== input) {
throw new HttpError(400, 'Invalid path', {
legacyCode: 'bad_request',
});
}
return input;
}
export function normalizeAbsolutePath(path: string): string {
const trimmed = typeof path === 'string' ? path.trim() : '';
if (trimmed.length === 0) {
@@ -137,7 +146,8 @@ export function normalizeAbsolutePath(path: string): string {
legacyCode: 'bad_request',
});
}
let normalized = pathPosix.normalize(trimmed);
assertNormalized(trimmed);
let normalized = trimmed;
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
+3 -3
View File
@@ -17,13 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Server as HttpServer } from 'node:http';
import { createAdapter } from '@socket.io/redis-streams-adapter';
import type { Server as HttpServer } from 'node:http';
import { Server as SocketIOServer, type Socket } from 'socket.io';
import type { Actor } from '../../core/actor.js';
import { isAppActor, isAccessTokenActor } from '../../core/actor.js';
import { PuterService } from '../types.js';
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
import type { AuthService } from '../auth/AuthService.js';
import { PuterService } from '../types.js';
/**
* Socket push target. A `room` fans to every socket in that room; a
+3 -1
View File
@@ -19,6 +19,7 @@
import { statfs } from 'node:fs/promises';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
import { v4 as uuidv4 } from 'uuid';
import { HttpError } from '../../core/http/HttpError.js';
import type { LayerInstances } from '../../types.js';
@@ -175,7 +176,8 @@ export class FSEntryStore extends PuterStore {
});
}
let normalized = pathPosix.normalize(trimmed);
assertNormalized(trimmed);
let normalized = trimmed;
if (!normalized.startsWith('/')) {
normalized = `/${normalized}`;
}
+19
View File
@@ -419,6 +419,25 @@ export class UserStore extends PuterStore {
}
}
async unconfirmOthersByEmail(
userId: number,
email: string,
cleanEmailValue: string,
): Promise<void> {
await this.clients.db.write(
`UPDATE \`user\`
SET \`email\` = NULL,
\`clean_email\` = NULL,
\`email_confirmed\` = 0,
\`requires_email_confirmation\` = 0,
\`email_confirm_code\` = NULL,
\`email_confirm_token\` = NULL
WHERE \`id\` != ?
AND (\`email\` = ? OR \`clean_email\` = ?)`,
[userId, email, cleanEmailValue],
);
}
async invalidate(user: UserRow): Promise<void> {
const keys = this.#cacheKeysForUser(user);
await this.publishCacheKeys({ keys, broadcast: true });
+13
View File
@@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the
| `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'` |
| `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 |
#### OpenAI Options
@@ -129,6 +130,18 @@ For more details, see the [Replicate API reference](https://replicate.com/docs)
Any properties not set fall back to provider defaults.
#### Saving to Puter filesystem
Pass `puter_output_path` to persist the generated image directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
```js
puter.ai.txt2img("A sunset over the mountains", {
puter_output_path: "images/sunset.png" // saved to ~/AppData/<appID>/images/sunset.png
});
```
Absolute paths (`/username/Pictures/sunset.png`) and home-relative paths (`~/Pictures/sunset.png`) are sent as-is. Write permission to the destination is enforced server-side.
## Return value
A `Promise` that resolves to an `HTMLImageElement`. The element’s `src` points at a data URL containing the image.
+13
View File
@@ -34,6 +34,7 @@ Additional settings for the generation request. Available options depend on the
| `model` | `String` | Video model to use (provider-specific). Defaults to `'sora-2'` |
| `seconds` | `Number` | Target clip length in seconds |
| `test_mode` | `Boolean` | When `true`, returns a sample video without using credits |
| `puter_output_path` | `String` | When set, the generated video 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 |
#### OpenAI Options
@@ -87,6 +88,18 @@ For more details about each option, see the [TogetherAI API reference](https://d
Any properties not set fall back to provider defaults.
#### Saving to Puter filesystem
Pass `puter_output_path` to persist the generated video directly on the Puter filesystem. Relative paths are resolved against `~/AppData/<appID>/` when called from an app, or `~/` otherwise:
```js
puter.ai.txt2vid("A drone shot over a forest", {
puter_output_path: "videos/forest.mp4" // saved to ~/AppData/<appID>/videos/forest.mp4
});
```
Absolute paths (`/username/Videos/forest.mp4`) and home-relative paths (`~/Videos/forest.mp4`) are sent as-is. Write permission to the destination is enforced server-side.
## Return value
A `Promise` that resolves to an `HTMLVideoElement`. The element is preloaded, has `controls` enabled, and exposes metadata via `data-mime-type` and `data-source` attributes. Append it to the DOM to display the generated clip immediately.
+2
View File
@@ -1,5 +1,7 @@
Puter.js brings free, serverless, Cloud and AI directly to your frontend JavaScript with no backend code or API keys required. Use the `@heyputer/puter.js` npm module or drop in a single `<script>` tag to instantly access file storage, databases, Claude, GPT, Gemini, and more right from your frontend code.
Puter.js is also uniquely suited for use by AI coding assistants, agents, and vibe coding platforms like Claude Code, Codex, OpenCode, Lovable, Replit, and more. Because Puter.js is keyless and serverless, AI-generated apps and programs using it work end-to-end without having to sign up for a third-party backend, provision a service, or paste in API keys. This eliminates both a major class of security issues and the setup friction that usually keeps these apps from running out of the box.
<div class="browser-window">
<div class="titlebar">
<div class="buttons">
File diff suppressed because it is too large. Load diff
-107
View File
@@ -44,7 +44,6 @@ import UIWindowFeedback from '../UIWindowFeedback.js';
// Import tab modules
import TabHome from './TabHome.js';
import TabFiles from './TabFiles.js';
import TabApps from './TabApps.js';
import TabUsage from './TabUsage.js';
import TabAccount from './TabAccount.js';
@@ -54,7 +53,6 @@ import TabSecurity from './TabSecurity.js';
const builtinTabs = [
TabHome,
TabApps,
TabFiles,
'-',
TabUsage,
TabAccount,
@@ -156,11 +154,6 @@ async function UIDashboard (options) {
$el_window.find('.dashboard-sidebar').addClass('collapsed');
}
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
}
// Initialize all tabs
for ( const tab of tabs ) {
if ( tab.init ) {
@@ -232,97 +225,6 @@ async function UIDashboard (options) {
}
});
// Trash status updates
window.socket.on('trash.is_empty', async (msg) => {
// Update sidebar Trash icon
const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
$('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon);
// If currently viewing trash and it's empty, clear the file list
const dashboard = window.dashboard_object;
if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) {
$('.files-tab .files').empty();
}
});
// =========================================================================
// Item event handlers
// Incremental DOM updates using UIDashboardFileItem for item creation and
// direct jQuery manipulation for removals/updates. Mirrors UIDesktop's
// approach but adapted for Dashboard's list-view structure.
// =========================================================================
window.socket.on('item.moved', async (resp) => {
if ( resp.original_client_socket_id === window.socket.id ) return;
// Fade out old item from view
$(`.item[data-uid='${resp.uid}']`).fadeOut(150, function () {
$(this).remove();
});
// Create new item at destination if user is viewing that directory
if ( window.UIDashboardFileItem ) {
window.UIDashboardFileItem(resp);
}
});
window.socket.on('item.removed', async (item) => {
if ( item.original_client_socket_id === window.socket.id ) return;
if ( item.descendants_only ) return;
$(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () {
$(this).remove();
});
});
window.socket.on('item.renamed', async (item) => {
if ( item.original_client_socket_id === window.socket.id ) return;
const $el = $(`.item[data-uid='${item.uid}']`);
if ( $el.length === 0 ) return;
// Update data attributes
$el.attr('data-name', html_encode(item.name));
$el.attr('data-path', html_encode(item.path));
// Update displayed name
$el.find('.item-name').text(item.name);
$el.find('.item-name-editor').val(item.name);
});
window.socket.on('item.updated', async (item) => {
const $el = $(`.item[data-uid='${item.uid}']`);
if ( $el.length === 0 ) return;
// Update data attributes
$el.attr('data-name', html_encode(item.name));
$el.attr('data-path', html_encode(item.path));
$el.attr('data-size', item.size);
$el.attr('data-modified', item.modified);
$el.attr('data-type', html_encode(item.type));
// Update displayed name
$el.find('.item-name').text(item.name);
$el.find('.item-name-editor').val(item.name);
if (
window.dashboard_object?.currentView === 'grid'
&& typeof item.thumbnail === 'string'
&& item.thumbnail.length > 0
) {
$el.find('.item-icon img').attr('src', item.thumbnail);
}
});
window.socket.on('item.added', async (item) => {
if ( !item || Object.keys(item).length === 0 ) return;
if ( item.original_client_socket_id === window.socket.id ) return;
if ( window.UIDashboardFileItem ) {
window.UIDashboardFileItem(item);
}
});
// Apply initial route from URL - activate the correct tab
if ( window.dashboard_initial_route ) {
const route = window.dashboard_initial_route;
@@ -356,7 +258,6 @@ async function UIDashboard (options) {
const handleRouteChange = () => {
const route = window.parseDashboardRoute();
const tab = route.tab;
const filePath = route.path;
// Switch to correct tab
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
@@ -378,14 +279,6 @@ async function UIDashboard (options) {
// Scroll content area to top
$el_window.find('.dashboard-content').scrollTop(0);
// If files tab with path, navigate without adding to history
if ( tab === 'files' && filePath ) {
const filesTab = tabs.find(t => t.id === 'files');
if ( filesTab?.renderDirectory ) {
filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true });
}
}
};
// Listen for both hashchange and popstate to handle all navigation scenarios
@@ -124,7 +124,9 @@ async function UIWindowFinalizeUserDeletion (options) {
return fetch(apiUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
};
+14 -6
View File
@@ -274,8 +274,9 @@ async function UIWindowLogin (options) {
let h = '';
h += '<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:center; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">';
// logo
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
h += '<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">';
h += `<img src="${window.icons['logo-white.svg']}" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 15px; background-color: blue; border-radius: 5px;">`;
h += `<img src="${window.icons['logo-white.svg']}" class="auth-logo" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 15px; background-color: blue; border-radius: 5px;${logo_clickable ? ' cursor: pointer;' : ''}">`;
h += '</div>';
// title
h += '<div style="padding:10px 20px; text-align:center; margin-bottom:0;">';
@@ -297,7 +298,10 @@ async function UIWindowLogin (options) {
h += '</div>';
// password
h += '<div style="position: relative; margin-bottom: 20px;">';
h += `<label style="display:block; margin-bottom:5px;">${i18n('password')}</label>`;
h += '<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">';
h += `<label style="margin-bottom:0;">${i18n('password')}</label>`;
h += `<span class="forgot-password-link" style="font-size:13px;">${i18n('forgot_pass_c2a')}</span>`;
h += '</div>';
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? 'text' : 'password'}" name="password" autocomplete="current-password"/>`;
// show/hide icon
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
@@ -306,14 +310,12 @@ async function UIWindowLogin (options) {
h += '</div>';
// login
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
// password recovery
h += `<p style="text-align:center; margin-bottom: 0;"><span class="forgot-password-link">${i18n('forgot_pass_c2a')}</span></p>`;
// OIDC
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 0 0 10px 0;">';
h += `<div style="text-align:center; margin: 10px 0; font-size:13px; color:var(--color-text-muted);">${ i18n('or') }</div>`;
h += `<div style="display:flex; align-items:center; gap:10px; margin: 10px 0; font-size:13px; color:#888;"><div style="flex:1; height:1px; background-color:#d0d0d0;"></div><span>${ i18n('or') }</span><div style="flex:1; height:1px; background-color:#d0d0d0;"></div></div>`;
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_in_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
h += '</div>';
h += '</form>';
h += '</div>';
@@ -377,6 +379,12 @@ async function UIWindowLogin (options) {
},
});
if ( logo_clickable ) {
$(el_window).find('.auth-logo').on('click', function () {
window.location.href = '/';
});
}
$(el_window).find('.forgot-password-link').on('click', function (e) {
UIWindowRecoverPassword({
window_options: {
+9 -2
View File
@@ -32,10 +32,12 @@ function UIWindowSignup (options) {
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
const logo_clickable = !!options.window_options?.cover_page && !window.embedded_in_popup;
let h = '';
h += '<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">';
// logo
h += `<img src="${window.icons['logo-white.svg']}" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 10px; background-color: blue; border-radius: 5px;">`;
h += `<img src="${window.icons['logo-white.svg']}" class="auth-logo" style="width: 40px; height: 40px; margin: 0 auto; display: block; padding: 10px; background-color: blue; border-radius: 5px;${logo_clickable ? ' cursor: pointer;' : ''}">`;
// close button
if ( !options.has_head && options.show_close_button !== false )
{
@@ -96,8 +98,8 @@ function UIWindowSignup (options) {
h += '</form>';
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_up_with_google')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
h += `<button type="button" class="oidc-microsoft-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 23 23" fill="none"><rect x="1" y="1" width="10" height="10" fill="#f25022"/><rect x="12" y="1" width="10" height="10" fill="#7fba00"/><rect x="1" y="12" width="10" height="10" fill="#00a4ef"/><rect x="12" y="12" width="10" height="10" fill="#ffb900"/></svg>${i18n('sign_up_with_microsoft')}</button>`;
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
h += '</div>';
// "Sign up with email" button, shown only when OIDC providers are available
h += `<div class="signup-with-email-wrapper" style="display:none;">`;
@@ -142,6 +144,11 @@ function UIWindowSignup (options) {
{
$(el_window).find('.username').get(0).focus({ preventScroll: true });
}
if ( logo_clickable ) {
$(el_window).find('.auth-logo').on('click', function () {
window.location.href = '/';
});
}
// Initialize Turnstile widget with callback to capture token
const initTurnstile = () => {
File diff suppressed because it is too large. Load diff
-13
View File
@@ -1910,19 +1910,6 @@ span.header-sort-icon img {
border: none;
}
/* TabFiles rubber band selection area */
.tabfiles-selection-area {
background-color: rgba(59, 130, 246, 0.15);
border: 1px solid var(--select-color);
position: absolute;
pointer-events: none;
z-index: 1000;
}
.dashboard-section-files .files {
position: relative;
}
.container {
user-select: none;
}
-8
View File
@@ -1735,10 +1735,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
suggested_apps: fsentry.suggested_apps,
};
UIItem(options);
// In dashboard mode, also create item via dashboard's renderer
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
window.UIDashboardFileItem(fsentry);
}
moved_items.push({ 'options': options, 'original_path': $(el_item).attr('data-path') });
// this operation may have created some missing directories,
@@ -1764,10 +1760,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
suggested_apps: dir.suggested_apps,
});
}
// In dashboard mode, also create parent dirs via dashboard's renderer
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
window.UIDashboardFileItem(dir);
}
window.sort_items(item_container);
});
+6 -12
View File
@@ -160,21 +160,15 @@ if ( window.location.pathname === '/dashboard' || window.location.pathname === '
/**
* Parses the dashboard URL hash into a route object.
* Hash format: #files/username/Documents or #usage or #account etc.
* @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path
* Hash format: #usage or #account etc.
* @returns {{ tab: string }} Route object with tab name
*/
function parseDashboardRoute () {
const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding
if ( ! hash ) return { tab: 'home', path: null };
const hash = decodeURIComponent(window.location.hash.slice(1));
if ( ! hash ) return { tab: 'home' };
const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']
const tab = parts[0]; // 'files', 'usage', 'account', 'security'
if ( tab === 'files' && parts.length > 1 ) {
const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents
return { tab: 'files', path: filePath };
}
return { tab: tab || 'home', path: null };
const tab = hash.split('/').filter(Boolean)[0];
return { tab: tab || 'home' };
}
// Make parseDashboardRoute available globally for hashchange handler
+206
View File
@@ -0,0 +1,206 @@
# Testing puter.js
This document covers the automated end-to-end test suite for `puter.js` UI APIs (`setMenubar`, `contextMenu`, etc.). The suite uses Playwright to drive a real Puter desktop and exercise the APIs the way real users hit them — including layout interaction bugs that pure-JS unit tests can't see.
The suite started as a regression net and is structured to grow incrementally as more `puter.ui` methods are covered.
## Quick start (first run on a new machine)
1. **Start Puter desktop** (monorepo root):
```sh
cd /path/to/puter
npm start
```
On first launch this prints a credentials block:
```
************************************************************
* Your default login credentials are:
* Username: admin
* Password: <hex string>
************************************************************
```
Copy the password — it's stable across restarts.
2. **Start the puter-js dev server** (serves `dist/puter.dev.js` and the test fixture on `localhost:8080`):
```sh
cd /path/to/puter/src/puter-js
npm start
```
Wait for webpack's "compiled successfully".
3. **Install Playwright browsers** (one-time):
```sh
cd /path/to/puter/src/puter-js
npm install
npm run playwright:install
```
4. **Set the admin password** (one-time, see [Setting the password](#setting-the-password) below for the recommended `.env` approach).
5. **Run the tests**:
```sh
cd /path/to/puter/src/puter-js
npm run test:e2e
```
## Setting the password
The admin password (from step 1) is the only credential the test suite needs. **Never hardcode it.** Two ways to provide it, pick one:
### Option A: `.env` file (recommended)
The `tests/e2e/.env` file is gitignored, so credentials never leave your machine.
```sh
cd /path/to/puter/src/puter-js
cp tests/e2e/.env.example tests/e2e/.env
```
Edit `tests/e2e/.env` and set the password you copied from step 1:
```
PUTER_ADMIN_PASSWORD=<your password>
```
That's it. All `npm run test:e2e*` commands pick it up automatically via `globalSetup`.
### Option B: shell environment variable
```sh
export PUTER_ADMIN_PASSWORD=<your password>
npm run test:e2e
```
Add the `export` to your shell rc (`~/.zshrc`, `~/.bashrc`) to persist across sessions. Shell env always wins over `.env`.
### When the password changes
The admin password is stored in Puter's KV under `tmp_password` and survives restarts, so it changes rarely (typically only if you wipe the local Puter database). When it does change, update your `.env` and force a fresh login:
```sh
PUTER_TEST_RESET_AUTH=1 npm run test:e2e
```
## Running tests
All commands run from `src/puter-js`:
| Command | What it does |
| --- | --- |
| `npm run test:e2e` | Headless run, both projects (`chromium` + `mobile-chromium`). |
| `npm run test:e2e:headed` | Same, but watches the browser drive Puter desktop — useful for debugging selectors. |
| `npm run test:e2e:ui` | Playwright UI mode (timeline scrubber, retry single test, picker for selectors). |
| `npm run test:e2e:record` | Records video, trace, and screenshots for **every** test (passing or failing). Files land in `test-results/<spec>/`. |
| `npm run test:e2e:report` | Opens the HTML report from the last run. |
| `npx playwright test --project=mobile-chromium` | Run only the mobile project (the contextMenu z-index regression test lives here). |
By default, video and trace are saved **only on failure**. `test:e2e:record` enables them for everything.
## How it works
```
┌─────────────────────────────────────────────────────────────┐
│ Playwright (chromium / mobile-chromium) │
│ ↓ navigates to │
│ http://puter.localhost:4100/app/puter-js-testing-<uuid> │
│ │
│ ┌─── Puter desktop (renders menus, contextMenus) ──────┐ │
│ │ ┌─── App iframe (the fixture) ──────────────────┐ │ │
│ │ │ loads dist/puter.dev.js │ │ │
│ │ │ calls puter.ui.setMenubar({...}) │ │ │
│ │ │ buttons trigger puter.ui.contextMenu({...}) │ │ │
│ │ │ logs interactions to <div id="log"> │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Authentication (once per run, cached for 24h)
`globalSetup` runs at the start of the test run:
1. Direct `POST /login` from Node with the admin credentials → JWT token.
2. Probes `window.api_origin` from Puter (server-templated) so we can pass it through.
3. Opens chromium once and navigates to `puter.localhost:4100/?auth_token=<token>&api_origin=<origin>`. Puter's `initgui` auth_token handler runs the full auth setup: `puter.setAuthToken`, `puter.setAPIOrigin`, `/session/sync-cookie`, `update_auth_data`.
4. Saves cookies + localStorage to `tests/e2e/.auth/state.json` (gitignored, cached for 24h).
Every test context loads with that `storageState` → already signed in as admin → no per-test `/login`, no `/signup`, no rate limits.
### Per-test flow
For each test:
1. **Test's `page` fixture** opens with the cached storageState — already authenticated.
2. **`registerTestApp(page)`** verifies the fixture URL is reachable, navigates to `puter.localhost:4100/`, waits for the SDK to settle, and calls `puter.apps.create('puter-js-testing-<uuid>', '<fixture URL>')`.
3. **`gotoTestApp(page, name)`** navigates to `puter.localhost:4100/app/<name>`. Puter desktop's `/app/<name>` handler calls `puter.apps.get(name)` → `launch_app(...)` → opens a window with an iframe at the fixture URL (with `?puter.app_instance_id=...` so puter.js detects `env=app`). Waits for `body.ready` inside the iframe.
4. **Test body**: clicks fixture buttons inside the iframe → fixture calls `puter.ui.setMenubar(...)` / `puter.ui.contextMenu(...)`. puter.js postMessages to Puter desktop → desktop renders `.window-menubar` / `.context-menu` in the **parent** DOM. Playwright asserts on those parent-frame elements and clicks items. Each click fires the item's `action` callback **back inside the iframe** (via Puter's RPC hydration), which appends a known string to `<div id="log">`. Assertions check that the right log entry appeared.
5. **`deleteTestApp(page, name)`** in `finally` — best-effort cleanup of the ephemeral app.
### Two viewports, one regression test
Both projects (`chromium` for desktop, `mobile-chromium` for `iPhone 13`) run the same specs. The mobile contextMenu test is the named regression for commit `aa5e398e`'s z-index bug — if the dismiss-overlay regresses to sit above the menu, the tap never reaches the item, the log stays empty, the test fails.
## File layout
```
src/puter-js/
├── playwright.config.js # projects, globalSetup, recording flags
├── TESTING.md # this file
└── tests/e2e/
├── README.md # short pointer, defers here
├── .env # YOUR password (gitignored)
├── .env.example # committed template, no secrets
├── .auth/ # cached auth state (gitignored)
├── globalSetup.js # signs in once, saves storageState
├── helpers/
│ └── testApp.js # registerTestApp / gotoTestApp / deleteTestApp / waitForPuterReady
├── fixtures/
│ └── menubar-contextmenu.html # the Puter app under test
└── specs/
├── menubar.spec.js
└── contextMenu.spec.js
```
The legacy `test/` (singular) folder is the manual browser harness for `puter.ai` / `puter.fs` / `puter.kv` / `puter.txt2speech` and stays as-is — different purpose, different audience.
## Adding new tests
The harness is set up to scale linearly. To cover another `puter.ui` method (e.g. `alert`, `prompt`, `notify`, `showOpenFilePicker`):
1. Add a button to `tests/e2e/fixtures/menubar-contextmenu.html` (or create a new fixture file) that calls the method with a known spec and logs the response into `<div id="log">`.
2. Add a spec under `tests/e2e/specs/` following the same shape as `menubar.spec.js`:
- `registerTestApp` → `gotoTestApp` → click fixture button → assert on Puter desktop's rendered DOM in the parent frame → click items / verify response → assert log entry appeared.
3. If the method has different desktop/mobile rendering, that's automatically exercised by both projects.
For methods that don't render UI (like `disableMenuItem`, `setMenuItemChecked`), assert on the resulting state changes in the menubar DOM instead of on log entries.
## Configuration reference
All env vars are optional unless marked required.
| Variable | Default | Purpose |
| --- | --- | --- |
| `PUTER_ADMIN_PASSWORD` | _(required)_ | Local Puter admin password (from `npm start`'s startup banner). Stable across restarts. |
| `PUTER_ADMIN_USERNAME` | `admin` | Local Puter admin username. |
| `PUTER_TEST_ORIGIN` | `http://puter.localhost:4100` | The Puter desktop origin Playwright drives. |
| `PUTER_TEST_FIXTURE_ORIGIN` | `http://localhost:8080` | Where the fixture HTML is served. The Puter app's `indexURL` is set to `${PUTER_TEST_FIXTURE_ORIGIN}/tests/e2e/fixtures/menubar-contextmenu.html`. |
| `PUTER_TEST_RECORD` | unset | When `1`, records video for every test (default: only on failure). |
| `PUTER_TEST_RESET_AUTH` | unset | When `1`, ignores the cached auth in `tests/e2e/.auth/state.json` and forces a fresh `/login`. Use after a password change or if Puter rejects the cached token. |
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `PUTER_ADMIN_PASSWORD is not set` | Env var or `.env` missing. | Follow [Setting the password](#setting-the-password). |
| `POST /login → HTTP 400: Username not found` | Local Puter hasn't created the admin user yet. | Run `npm start` from monorepo root and wait for the credentials banner. |
| `POST /login → HTTP 400: Invalid password.` | Password in your `.env` is wrong. | Re-check the banner, update `.env`, then `PUTER_TEST_RESET_AUTH=1 npm run test:e2e`. |
| `POST /login → HTTP 403: Forbidden` | Puter's CSRF check rejected the request. | Should not happen — globalSetup sets the `Origin` header. If it does, confirm `PUTER_TEST_ORIGIN` matches Puter's actual origin. |
| `Fixture URL is unreachable` | puter-js dev server isn't running. | From `src/puter-js`: `npm start`, wait for "compiled successfully". |
| `puter.apps.create failed: Unauthorized` with `APIOrigin: "https://api.puter.com"` | The cached `storageState` is from before the api_origin fix. | `rm -rf tests/e2e/.auth && npm run test:e2e`. |
| Test launches a window but iframe stays blank | puter-js dev server stopped mid-run, or fixture path is wrong. | Restart `npm start` in `src/puter-js` and re-run. |
| `body.ready` timeout in `gotoTestApp` | Puter desktop didn't open the app window. Usually means `puter.apps.get(name)` 404'd. | Check the test trace for `puter.apps.create` errors above this one. |
| Mobile contextMenu test fails on `expect(log entry).toBeVisible()` | The actual bug we're guarding against — overlay z-index regression on mobile. | Fix the GUI's `.context-menu-sheet-backdrop` z-index (see commit `aa5e398e`). |
## CI
Not wired up yet. Local runs depend on Puter being started via `npm start` (which auto-creates the admin user on first launch). CI will need a different bootstrap path — TBD when we get there.
+7
View File
@@ -31,12 +31,19 @@
"start-webpack": "webpack --stats=errors-only && webpack --output-filename puter.dev.js --watch --devtool source-map --stats=errors-only",
"start": "concurrently \"npm run start-server\" \"npm run start-webpack\"",
"test": "npx http-server --cors -c-1 -o /test",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:e2e:record": "PUTER_TEST_RECORD=1 playwright test",
"test:e2e:report": "playwright show-report",
"playwright:install": "playwright install chromium",
"build": "webpack && { echo \"// Copyright 2024-present Puter Technologies Inc. All rights reserved.\"; echo \"// Generated on $(date '+%Y-%m-%d %H:%M')\n\"; cat ./dist/puter.js; } > temp && mv temp ./dist/puter.js",
"prepublishOnly": "npm run build && mv dist/puter.js dist/puter.cjs && npm version patch"
},
"author": "Puter Technologies Inc.",
"license": "Apache-2.0",
"devDependencies": {
"@playwright/test": "^1.49.0",
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"webpack-cli": "^5.1.4"
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
const FIXTURE_ORIGIN = process.env.PUTER_TEST_FIXTURE_ORIGIN || 'http://localhost:8080';
const RECORD_ALL = !!process.env.PUTER_TEST_RECORD;
const STORAGE_STATE_PATH = path.join(__dirname, 'tests', 'e2e', '.auth', 'state.json');
export default defineConfig({
testDir: './tests/e2e/specs',
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
timeout: 120_000,
expect: { timeout: 15_000 },
globalSetup: './tests/e2e/globalSetup.js',
use: {
baseURL: PUTER_ORIGIN,
storageState: STORAGE_STATE_PATH,
trace: 'retain-on-failure',
video: RECORD_ALL ? 'on' : 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile-chromium',
use: { ...devices['iPhone 13'] },
},
],
metadata: {
puterOrigin: PUTER_ORIGIN,
fixtureOrigin: FIXTURE_ORIGIN,
},
});
+2 -2
View File
@@ -521,7 +521,7 @@ async function driverCall_ (
}
// HTTP Error - unauthorized
if ( response.status === 401 || resp?.code === 'token_auth_failed' ) {
if ( response.target.status === 401 || resp?.code === 'token_auth_failed' ) {
if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) {
try {
puter.resetAuthToken();
@@ -543,7 +543,7 @@ async function driverCall_ (
return reject_func({ status: 401, message: 'Unauthorized' });
}
// HTTP Error - other
else if ( response.status && response.status !== 200 ) {
else if ( response.target.status && response.target.status !== 200 ) {
// if error callback is provided, call it
error_cb(resp);
// reject promise
+10
View File
@@ -1,4 +1,5 @@
import * as utils from '../lib/utils.js';
import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js';
const normalizeTTSProvider = (value) => {
if ( typeof value !== 'string' ) {
@@ -910,6 +911,11 @@ class AI {
} else {
AIService = 'ai-image';
}
if ( options.puter_output_path ) {
options.puter_output_path = getAbsolutePathForApp(options.puter_output_path);
}
// Call the original chat.complete method
return await utils.make_driver_method(['prompt'], 'puter-image-generation', AIService, 'generate', {
responseType: 'blob',
@@ -983,6 +989,10 @@ class AI {
videoService = driverHint;
}
if ( options.puter_output_path ) {
options.puter_output_path = getAbsolutePathForApp(options.puter_output_path);
}
return await utils.make_driver_method(['prompt'], 'puter-video-generation', videoService, 'generate', {
responseType: 'blob',
test_mode: testMode ?? false,
+55
View File
@@ -12,9 +12,64 @@ class PuterWebComponent extends (globalThis.HTMLElement || Object) {
}
connectedCallback () {
this._setupThemeWatchers();
this._applyTheme();
this._rerender();
}
disconnectedCallback () {
this._teardownThemeWatchers();
}
_setupThemeWatchers () {
if ( typeof globalThis.MutationObserver !== 'undefined' && ! this._themeObserver ) {
this._themeObserver = new globalThis.MutationObserver(() => this._applyTheme());
this._themeObserver.observe(this, { attributes: true, attributeFilter: ['theme'] });
}
if ( typeof globalThis.matchMedia === 'function' && ! this._themeMediaQuery ) {
this._themeMediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)');
this._themeMediaListener = () => this._applyTheme();
if ( this._themeMediaQuery.addEventListener ) {
this._themeMediaQuery.addEventListener('change', this._themeMediaListener);
}
}
}
_teardownThemeWatchers () {
if ( this._themeObserver ) {
this._themeObserver.disconnect();
this._themeObserver = null;
}
if ( this._themeMediaQuery && this._themeMediaListener && this._themeMediaQuery.removeEventListener ) {
this._themeMediaQuery.removeEventListener('change', this._themeMediaListener);
}
this._themeMediaQuery = null;
this._themeMediaListener = null;
}
/**
* Resolves the effective theme from the `theme` attribute and the system
* preference, then toggles a `.puter-theme-dark` class on the host so
* components can style with `:host(.puter-theme-dark) ...`.
* theme="dark" → always dark
* theme="light" → always light
* unset / other → follow system (prefers-color-scheme)
*/
_applyTheme () {
if ( typeof this.getAttribute !== 'function' ) return;
const themeAttr = this.getAttribute('theme');
let isDark;
if ( themeAttr === 'dark' ) {
isDark = true;
} else if ( themeAttr === 'light' ) {
isDark = false;
} else {
isDark = typeof globalThis.matchMedia === 'function'
&& globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
}
this.classList.toggle('puter-theme-dark', isDark);
}
_rerender () {
if ( ! this.shadowRoot ) return;
this.shadowRoot.innerHTML = `<style>${this.getStyles()}</style>${this.render()}`;
+19 -1
View File
@@ -340,7 +340,25 @@ All components render inside Shadow DOM and match puter.com's native GUI appeara
## Dark mode
Components with `@media (prefers-color-scheme: dark)` rules auto-adapt when the OS is in dark mode: `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
To force a specific theme regardless of the OS preference, set the `theme` attribute on the component:
| Value | Behavior |
|-------|----------|
| `dark` | Always render in dark theme |
| `light` | Always render in light theme |
| _unset_ | Follow the system `prefers-color-scheme` (default) |
```html
<!-- Force dark, even in an OS configured for light mode -->
<puter-menubar theme="dark"></puter-menubar>
<!-- Force light, even when the OS is in dark mode -->
<puter-notification theme="light" title="Hello"></puter-notification>
```
The attribute is live — changing it at runtime re-paints the component immediately. For `<puter-menubar>` and `<puter-context-menu>`, the `theme` is forwarded to any dropdowns and submenus they spawn, so the whole tree stays in sync.
## Responsive / mobile
@@ -176,6 +176,46 @@ class PuterColorPicker extends PuterWebComponent {
flex: 1;
}
}
:host(.puter-theme-dark) .picker-body {
background-color: rgba(40, 44, 52, .95);
color: #e6e6e6;
box-shadow: 0px 0px 15px #000000aa;
}
:host(.puter-theme-dark) .preview {
border-color: #555;
}
:host(.puter-theme-dark) .header-label {
color: #aaa;
}
:host(.puter-theme-dark) .hex-input {
background-color: #1f1f1f;
border-color: #555;
color: #e6e6e6;
}
:host(.puter-theme-dark) .native-color-row {
background: rgba(255, 255, 255, 0.05);
border-color: #555;
}
:host(.puter-theme-dark) .native-color-row label {
color: #aaa;
}
:host(.puter-theme-dark) input[type="color"] {
border-color: #555;
}
:host(.puter-theme-dark) .swatch {
border-color: rgba(255, 255, 255, 0.1);
}
:host(.puter-theme-dark) .btn {
color: #e6e6e6;
border-color: #555;
background: linear-gradient(#4a4a4a, #3a3a3a);
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
}
:host(.puter-theme-dark) .btn:active {
background-color: #333;
border-color: #444;
color: #999;
}
`;
}
@@ -47,15 +47,16 @@ class PuterContextMenu extends PuterWebComponent {
font-family: sans-serif;
background: #FFF;
color: #333;
border-radius: 2px;
border-radius: 4px;
padding: 3px 0;
min-width: 200px;
background-color: rgb(255 255 255 / 92%);
backdrop-filter: blur(3px);
border: 1px solid #e6e4e466;
box-shadow: 0px 0px 15px #00000066;
padding-left: 6px;
padding-right: 6px;
box-shadow: 0px 3px 10px #00000044;
margin-top: 5px;
padding-left: 4px;
padding-right: 4px;
padding-top: 4px;
padding-bottom: 4px;
user-select: none;
@@ -88,35 +89,68 @@ class PuterContextMenu extends PuterWebComponent {
border-radius: 4px;
}
/* Active item turns all children white */
/* Active item turns all children white.
For .has-open-submenu the :hover branch above already covers
the hovered case; in the non-hovered grey state we want the
children to keep their default colors, so we don't include
.has-open-submenu here. */
.menu-item:hover:not(.disabled):not(.divider) .icon,
.menu-item:hover:not(.disabled):not(.divider) .check,
.menu-item:hover:not(.disabled):not(.divider) .submenu-arrow,
.menu-item:hover:not(.disabled):not(.divider) .shortcut,
.menu-item:hover:not(.disabled):not(.divider) .label,
.menu-item:hover:not(.disabled):not(.divider) .shortcut,
.menu-item.focused:not(.disabled):not(.divider) .icon,
.menu-item.focused:not(.disabled):not(.divider) .check,
.menu-item.focused:not(.disabled):not(.divider) .submenu-arrow,
.menu-item.focused:not(.disabled):not(.divider) .shortcut,
.menu-item.focused:not(.disabled):not(.divider) .label,
.menu-item.has-open-submenu .icon,
.menu-item.has-open-submenu .check,
.menu-item.has-open-submenu .submenu-arrow,
.menu-item.has-open-submenu .shortcut,
.menu-item.has-open-submenu .label {
.menu-item.focused:not(.disabled):not(.divider) .shortcut {
color: white;
}
.menu-item:hover:not(.disabled):not(.divider) .icon svg,
.menu-item.focused:not(.disabled):not(.divider) .icon svg,
.menu-item.has-open-submenu .icon svg {
.menu-item.focused:not(.disabled):not(.divider) .icon svg {
filter: brightness(0) invert(1);
}
.menu-item:hover:not(.disabled):not(.divider) .icon img,
.menu-item.focused:not(.disabled):not(.divider) .icon img,
.menu-item.has-open-submenu .icon img {
.menu-item.focused:not(.disabled):not(.divider) .icon img {
filter: brightness(0) invert(1);
}
/* Safe-triangle: while the cursor traces a diagonal path toward
an open submenu, suppress :hover highlight on intermediate
items so they don't flash blue.
Keyboard-nav: after a keyboard navigation, suppress :hover on
the (now stale) item the mouse is still resting on so only
the keyboard-focused item highlights. Cleared on next
mousemove. .focused and .has-open-submenu (managed by JS)
still highlight normally. */
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
background-color: transparent;
color: #333;
}
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
color: #333;
}
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
color: #999;
}
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
filter: none;
}
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51));
}
/* .has-open-context-menu-submenu — line 1738-1739 */
.menu-item.has-open-submenu:not(:hover) {
background-color: #dfdfdf;
@@ -151,7 +185,6 @@ class PuterContextMenu extends PuterWebComponent {
padding-bottom: 5px;
cursor: default;
height: auto;
pointer-events: none;
}
.divider hr {
border: none;
@@ -213,6 +246,8 @@ class PuterContextMenu extends PuterWebComponent {
margin-left: 16px;
font-size: 11px;
color: #999;
flex-shrink: 0;
letter-spacing: 0.5px;
}
/* === iOS-style action sheet (mobile) ========================= */
@@ -261,6 +296,14 @@ class PuterContextMenu extends PuterWebComponent {
border-radius: 0;
}
/* Keyboard shortcuts have no meaning on touch / small-screen
devices — hide them so labels can use the full width. */
@media (max-width: 480px), (pointer: coarse) {
.shortcut {
display: none;
}
}
:host(.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) {
background-color: rgba(0, 122, 255, 0.1);
color: inherit;
@@ -294,13 +337,109 @@ class PuterContextMenu extends PuterWebComponent {
:host(.sheet-mode) .icon {
width: 24px;
margin-right: 12px;
margin-right: 0px;
}
:host(.sheet-mode) .icon svg,
:host(.sheet-mode) .icon img {
width: 20px;
height: 20px;
}
/* Dark theme — applied when system prefers dark and no light
override is set, or when theme="dark" is forced. The base
class toggles .puter-theme-dark on the host accordingly. */
:host(.puter-theme-dark) .context-menu {
background: #2d2d2d;
background-color: rgb(45 45 45 / 94%);
color: #e6e6e6;
border-color: #00000080;
box-shadow: 0px 0px 15px #000000aa;
}
:host(.puter-theme-dark) .menu-item {
color: #e6e6e6;
}
/* Inactive items: icon/check/shortcut/arrow tones */
:host(.puter-theme-dark) .icon,
:host(.puter-theme-dark) .check {
color: #e6e6e6;
}
:host(.puter-theme-dark) .submenu-arrow {
color: #b0b0b0;
}
:host(.puter-theme-dark) .shortcut {
color: #888;
}
:host(.puter-theme-dark) .icon img {
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
}
/* Inactive icon SVGs use currentColor already; nothing to invert */
/* Safe-triangle / keyboard-nav: non-active hover restored colors should match dark */
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
color: #e6e6e6;
}
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
color: #e6e6e6;
}
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
color: #888;
}
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
}
/* Submenu-open parent (no hover): subtle dark highlight */
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) {
background-color: #3f3f3f;
color: #e6e6e6;
}
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon,
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon svg,
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon img {
color: #e6e6e6;
}
/* Danger items */
:host(.puter-theme-dark) .menu-item.danger,
:host(.puter-theme-dark) .menu-item.danger .icon {
color: #ff7b72;
}
/* Divider */
:host(.puter-theme-dark) .divider hr {
background: #444;
}
/* Sheet mode (mobile) */
:host(.puter-theme-dark.sheet-mode) .context-menu {
background-color: rgb(40 40 40 / 96%);
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45);
}
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) {
background-color: rgba(0, 122, 255, 0.22);
}
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) {
background-color: rgba(0, 122, 255, 0.35);
}
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .label,
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .icon,
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .label,
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .icon {
color: #e6e6e6;
}
:host(.puter-theme-dark.sheet-mode) .divider hr {
background: rgba(255, 255, 255, 0.15);
}
`;
}
@@ -342,7 +481,7 @@ class PuterContextMenu extends PuterWebComponent {
: '';
const shortcutHTML = item.shortcut
? `<span class="shortcut">${this._escapeHTML(item.shortcut)}</span>`
? `<span class="shortcut">${this._escapeHTML(this._formatShortcut(item.shortcut))}</span>`
: '';
return `
@@ -431,7 +570,7 @@ class PuterContextMenu extends PuterWebComponent {
_bindEvents () {
// Remove any stale document listeners from a prior render
if ( this._outsideClickHandler ) {
document.removeEventListener('click', this._outsideClickHandler, true);
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
}
if ( this._keyHandler ) {
document.removeEventListener('keydown', this._keyHandler, true);
@@ -441,6 +580,33 @@ class PuterContextMenu extends PuterWebComponent {
this.#mouseTracker = null;
}
// Hovering a separator or disabled item should drop the focus
// highlight from the previously-focused item — the cursor has
// clearly moved past it. It should also close any submenu opened
// from an item above, so the parent's .has-open-submenu highlight
// goes away too.
// Exception: if the cursor is on a diagonal path toward the open
// submenu, treat these like any other safe-traverse cell so we
// don't tear down the submenu mid-drag.
this.$$('.menu-item.divider, .menu-item.disabled').forEach((el) => {
el.addEventListener('mouseenter', () => {
if ( this.#activeSubmenu && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
this._setSafeTraverse(true);
if ( this.#submenuCloseTimer ) {
clearTimeout(this.#submenuCloseTimer);
this.#submenuCloseTimer = null;
}
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
return;
}
this._setSafeTraverse(false);
this._clearFocus();
clearTimeout(this.#submenuTimeout);
this._cancelSubmenuClose();
this._hideActiveSubmenu();
});
});
const menuItems = this.$$('.menu-item:not(.divider):not(.disabled)');
menuItems.forEach((el) => {
@@ -471,7 +637,25 @@ class PuterContextMenu extends PuterWebComponent {
// Hover: mouse takes over focus highlight
el.addEventListener('mouseenter', () => {
if ( el.dataset.hasSubmenu === 'true' ) {
// Safe-triangle: if a *different* submenu is already
// open and the cursor is tracing diagonally toward it,
// defer the swap. Without this, two adjacent items
// with submenus would close the target as soon as the
// cursor passed over the second one.
if ( this.#activeSubmenu
&& this.#activeSubmenu.parentEl !== el
&& this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
this.#pendingFocusIndex = index;
this._setSafeTraverse(true);
if ( this.#submenuCloseTimer ) {
clearTimeout(this.#submenuCloseTimer);
this.#submenuCloseTimer = null;
}
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
return;
}
this.#pendingFocusIndex = null;
this._setSafeTraverse(false);
this._setFocusIndex(index);
this._cancelSubmenuClose();
clearTimeout(this.#submenuTimeout);
@@ -485,21 +669,34 @@ class PuterContextMenu extends PuterWebComponent {
}
} else if ( this.#activeSubmenu ) {
// Safe-triangle: if cursor is heading toward the submenu,
// defer focus change so intermediate items don't highlight
// defer focus change AND suppress :hover styling on this
// item so it doesn't flash blue mid-traversal.
if ( this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
this.#pendingFocusIndex = index;
this._cancelSubmenuClose();
this._setSafeTraverse(true);
// Don't call _cancelSubmenuClose — it clears
// pendingFocusIndex. Just clear the close timer.
if ( this.#submenuCloseTimer ) {
clearTimeout(this.#submenuCloseTimer);
this.#submenuCloseTimer = null;
}
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
return;
}
this._setSafeTraverse(false);
this._setFocusIndex(index);
this._scheduleSubmenuClose();
} else {
this._setSafeTraverse(false);
this._setFocusIndex(index);
}
});
el.addEventListener('mouseleave', () => {
// iOS Safari synthesizes mouseleave on touchend after a tap,
// which would schedule the just-opened submenu to close.
// On touch, submenus are driven by explicit taps, not hover.
if ( this._isMobile() ) return;
clearTimeout(this.#submenuTimeout);
if ( el.dataset.hasSubmenu === 'true' && this.#activeSubmenu && this.#activeSubmenu.parentEl === el ) {
this._scheduleSubmenuClose();
@@ -507,20 +704,37 @@ class PuterContextMenu extends PuterWebComponent {
});
});
// Close on outside click
// When the cursor leaves the menu entirely, drop the item highlight
// so the user isn't left with a stale focus mark while the cursor
// is elsewhere. Submenu parents use .has-open-submenu (not .focused),
// so this is safe even while a submenu is open.
const menuRoot = this.$('.context-menu');
if ( menuRoot ) {
menuRoot.addEventListener('mouseleave', () => {
this._clearFocus();
});
}
// Close on outside pointerdown — fires the instant the press starts,
// before mouseup/click, so the menu doesn't linger during a drag.
// Submenus are sibling elements appended to <body>, so we explicitly
// walk the submenu chain — a click in a descendant submenu must not
// tear us (and therefore that submenu) down.
this._outsideClickHandler = (e) => {
if ( ! this.contains(e.target) ) {
if ( ! this._isEventInChain(e) ) {
this._closeAll();
}
};
setTimeout(() => {
document.addEventListener('click', this._outsideClickHandler, true);
document.addEventListener('pointerdown', this._outsideClickHandler, true);
}, 0);
// Track mouse for safe-triangle submenu hover
// Track mouse for safe-triangle submenu hover. Also exits
// keyboard-nav mode: once the cursor moves, :hover should win again.
this.#mouseTracker = (e) => {
this.#mouseLocs.push({ x: e.clientX, y: e.clientY });
if ( this.#mouseLocs.length > 3 ) this.#mouseLocs.shift();
this._setKeyboardNav(false);
};
document.addEventListener('mousemove', this.#mouseTracker);
@@ -529,6 +743,10 @@ class PuterContextMenu extends PuterWebComponent {
if ( this.#activeSubmenu ) return; // let the deeper submenu handle
const consumed = this._handleKey(e);
if ( consumed ) {
// A handled key (nav, typeahead, etc.) means the user is
// driving by keyboard — suppress any stale :hover until the
// mouse next moves.
this._setKeyboardNav(true);
e.preventDefault();
e.stopImmediatePropagation();
}
@@ -547,9 +765,24 @@ class PuterContextMenu extends PuterWebComponent {
case 'ArrowDown':
this._moveFocus(+1);
return true;
case 'ArrowUp':
case 'ArrowUp': {
// Root dropdown: ArrowUp on the first focusable item bubbles
// to the menubar, which closes the dropdown and re-focuses
// the parent menubar button.
if ( ! this._parentMenu ) {
const f = this._focusableIndices();
if ( f.length && this.#focusedIndex === f[0] ) {
this.dispatchEvent(new CustomEvent('puter-menu-navigate', {
detail: { direction: 'up' },
bubbles: true,
composed: true,
}));
return true;
}
}
this._moveFocus(-1);
return true;
}
case 'Home': {
const f = this._focusableIndices();
if ( f.length ) this._setFocusIndex(f[0]);
@@ -660,14 +893,23 @@ class PuterContextMenu extends PuterWebComponent {
if ( ! el ) return false;
clearTimeout(this.#submenuTimeout);
this._cancelSubmenuClose();
this._showSubmenu(el, item.items);
// Focus first item in submenu
// Don't re-open an already-open submenu for this parent — it would
// wipe the user's focused item inside it. We only auto-focus the
// first sub-item when the submenu is freshly opened by this call.
const wasNewlyOpened = !this.#activeSubmenu || this.#activeSubmenu.parentEl !== el;
if ( wasNewlyOpened ) {
this._showSubmenu(el, item.items);
}
requestAnimationFrame(() => {
const sub = this.#activeSubmenu && this.#activeSubmenu.element;
if ( sub ) {
if ( ! sub ) return;
if ( wasNewlyOpened ) {
const f = sub._focusableIndices();
if ( f.length ) sub._setFocusIndex(f[0]);
}
if ( typeof sub._setKeyboardNav === 'function' ) {
sub._setKeyboardNav(true);
}
});
return true;
}
@@ -713,10 +955,18 @@ class PuterContextMenu extends PuterWebComponent {
this._cancelSubmenuClose();
parentEl.classList.add('has-open-submenu');
// The parent now "owns" an open submenu; drop the .focused class so
// it paints in the dim has-open-submenu state instead of the bright
// focused/hover blue. :hover still keeps it blue when the cursor is
// directly over it. #focusedIndex is preserved for ArrowLeft restore.
parentEl.classList.remove('focused');
const submenu = document.createElement('puter-context-menu');
submenu.setAttribute('data-submenu', '');
submenu.setAttribute('data-parent-managed', '');
// Forward any forced theme so the submenu paints in the same theme.
const themeAttr = this.getAttribute('theme');
if ( themeAttr ) submenu.setAttribute('theme', themeAttr);
submenu.items = items;
submenu._parentMenu = this;
submenu._parentItemEl = parentEl;
@@ -809,13 +1059,17 @@ class PuterContextMenu extends PuterWebComponent {
clearTimeout(this.#submenuCloseTimer);
this.#submenuCloseTimer = null;
}
// User reached the submenu — discard deferred focus
// User reached the submenu — discard deferred focus and end traversal
this.#pendingFocusIndex = null;
this._setSafeTraverse(false);
}
_submenuCloseCheck () {
this.#submenuCloseTimer = null;
if ( ! this.#activeSubmenu ) return;
if ( ! this.#activeSubmenu ) {
this._setSafeTraverse(false);
return;
}
// If cursor is currently over the submenu or the parent item, keep open
const submenu = this.#activeSubmenu.element;
@@ -823,6 +1077,8 @@ class PuterContextMenu extends PuterWebComponent {
const latest = this.#mouseLocs[this.#mouseLocs.length - 1];
if ( latest ) {
if ( this._pointInElement(latest, submenu) || this._pointInRect(latest, parentEl.getBoundingClientRect()) ) {
// Cursor arrived at submenu / parent — end safe-triangle mode
this._setSafeTraverse(false);
return;
}
}
@@ -834,9 +1090,21 @@ class PuterContextMenu extends PuterWebComponent {
return;
}
// Trajectory no longer heading to submenu — end traversal mode
this._setSafeTraverse(false);
this._hideActiveSubmenu();
}
_setSafeTraverse (on) {
const menu = this.$('.context-menu');
if ( menu ) menu.classList.toggle('safe-traverse', on);
}
_setKeyboardNav (on) {
const menu = this.$('.context-menu');
if ( menu ) menu.classList.toggle('keyboard-nav', on);
}
_pointInRect (p, r) {
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
}
@@ -906,11 +1174,21 @@ class PuterContextMenu extends PuterWebComponent {
this.style.display = '';
this._sheetHidden = false;
}
// Apply deferred focus from safe-triangle hover
// Apply deferred focus from safe-triangle hover. If the deferred
// item is itself a submenu opener, open it now — the user crossed
// the safe triangle without reaching the previous submenu, so we
// commit to the new target.
if ( this.#pendingFocusIndex !== null ) {
this._setFocusIndex(this.#pendingFocusIndex);
const idx = this.#pendingFocusIndex;
this.#pendingFocusIndex = null;
this._setFocusIndex(idx);
const pending = this.#items[idx];
if ( pending && pending.items && pending.items.length ) {
const pendingEl = this._itemEl(idx);
if ( pendingEl ) this._showSubmenu(pendingEl, pending.items);
}
}
this._setSafeTraverse(false);
}
_closeAll () {
@@ -924,7 +1202,7 @@ class PuterContextMenu extends PuterWebComponent {
const wasHidden = this._sheetHidden;
this._hideActiveSubmenu(false);
if ( this._outsideClickHandler ) {
document.removeEventListener('click', this._outsideClickHandler, true);
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
}
if ( this._keyHandler ) {
document.removeEventListener('keydown', this._keyHandler, true);
@@ -952,8 +1230,9 @@ class PuterContextMenu extends PuterWebComponent {
}
disconnectedCallback () {
super.disconnectedCallback();
if ( this._outsideClickHandler ) {
document.removeEventListener('click', this._outsideClickHandler, true);
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
}
if ( this._keyHandler ) {
document.removeEventListener('keydown', this._keyHandler, true);
@@ -980,6 +1259,89 @@ class PuterContextMenu extends PuterWebComponent {
if ( ! str ) return '';
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Render a keyboard-shortcut string in OS-appropriate form.
*
* Mac: modifiers as glyphs, concatenated → ⇧⌘D
* Win/Linux: text labels joined with '+' → Ctrl+Shift+D
*
* Accepts portable tokens (Mod, Cmd, Ctrl, Alt, Option, Shift, Meta) and
* the literal Mac glyphs (⌘ ⌃ ⌥ ⇧). 'Mod' is the recommended portable
* name — it maps to Cmd on Mac and Ctrl elsewhere.
*/
_formatShortcut (str) {
if ( ! str ) return '';
const isMac = PuterContextMenu._isMac();
// Inflate any glyphs into named tokens so we can re-emit per OS.
const normalized = String(str)
.replace(/⌘/g, 'Mod+') // ⌘
.replace(/⌃/g, 'Ctrl+') // ⌃
.replace(/⌥/g, 'Alt+') // ⌥
.replace(/⇧/g, 'Shift+'); // ⇧
const tokens = normalized.split('+').map(t => t.trim()).filter(Boolean);
const out = tokens.map(t => {
switch ( t.toLowerCase() ) {
case 'mod':
case 'cmd':
case 'command':
return isMac ? '⌘' : 'Ctrl';
case 'ctrl':
case 'control':
return isMac ? '⌃' : 'Ctrl';
case 'alt':
case 'option':
case 'opt':
return isMac ? '⌥' : 'Alt';
case 'shift':
return isMac ? '⇧' : 'Shift';
case 'meta':
case 'super':
case 'win':
return isMac ? '⌘' : 'Win';
default:
return t;
}
});
return isMac ? out.join('') : out.join('+');
}
_getActiveSubmenu () {
return this.#activeSubmenu;
}
/**
* Returns true if a document-level event targets this menu or any
* submenu nested below it. e.target on shadow-DOM-crossing events is
* the submenu's host element, so we use contains() on each host in the
* chain.
*/
_isEventInChain (e) {
const target = e.target;
if ( ! target ) return false;
if ( this.contains(target) ) return true;
let cur = this.#activeSubmenu;
while ( cur && cur.element ) {
if ( cur.element.contains(target) ) return true;
cur = cur.element._getActiveSubmenu
? cur.element._getActiveSubmenu()
: null;
}
return false;
}
static _isMac () {
if ( typeof navigator === 'undefined' ) return false;
const uaData = navigator.userAgentData;
if ( uaData && typeof uaData.platform === 'string' ) {
return /mac/i.test(uaData.platform);
}
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');
}
}
export default PuterContextMenu;
@@ -185,6 +185,49 @@ class PuterFontPicker extends PuterWebComponent {
flex: 1;
}
}
:host(.puter-theme-dark) .picker-body {
background-color: rgba(40, 44, 52, .95);
color: #e6e6e6;
box-shadow: 0px 0px 15px #000000aa;
}
:host(.puter-theme-dark) .title {
color: #e6e6e6;
text-shadow: none;
}
:host(.puter-theme-dark) .search {
background-color: #1f1f1f;
border-color: #555;
color: #e6e6e6;
}
:host(.puter-theme-dark) .font-list {
background-color: #1f1f1f;
border-color: #555;
}
:host(.puter-theme-dark) .font-item {
color: #e6e6e6;
}
:host(.puter-theme-dark) .font-item:hover {
background: rgba(255, 255, 255, 0.06);
}
:host(.puter-theme-dark) .font-name-label {
color: #888;
}
:host(.puter-theme-dark) .preview {
background: #1f1f1f;
border-color: #555;
color: #e6e6e6;
}
:host(.puter-theme-dark) .btn {
color: #e6e6e6;
border-color: #555;
background: linear-gradient(#4a4a4a, #3a3a3a);
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
}
:host(.puter-theme-dark) .btn:active {
background-color: #333;
border-color: #444;
color: #999;
}
`;
}
+186 -14
View File
@@ -63,12 +63,25 @@ class PuterMenubar extends PuterWebComponent {
line-height: 1.2;
margin: 0 1px;
}
.menu-button:hover,
/* Hover is driven by JS (.hovered) rather than :hover so we can
clear stale keyboard focus the moment the mouse takes over and
keep the two highlight sources from ever co-existing. */
.menu-button.hovered,
.menu-button.active,
.menu-button.focused {
background-color: #e2e2e2;
}
.menu-button:focus { outline: none; }
/* Suppress browser-native focus ring and tap highlight without
touching background. CAUTION: setting background-color here
would tie specificity with .menu-button.active and win by
source order, which would erase the open-menu highlight as
soon as the button takes DOM :focus from a click. */
.menu-button:focus,
.menu-button:focus-visible,
.menu-button:active {
outline: none;
-webkit-tap-highlight-color: transparent;
}
@media (max-width: 480px) {
.menubar {
height: 40px;
@@ -86,6 +99,21 @@ class PuterMenubar extends PuterWebComponent {
flex-shrink: 0;
}
}
/* Dark theme — applied when system prefers dark and no light
override is set, or when theme="dark" is forced. The base
class toggles .puter-theme-dark on the host accordingly. */
:host(.puter-theme-dark) .menubar {
background-color: #2a2a2a;
border-bottom-color: #3a3a3a;
}
:host(.puter-theme-dark) .menu-button {
color: #e6e6e6;
}
:host(.puter-theme-dark) .menu-button.hovered,
:host(.puter-theme-dark) .menu-button.active,
:host(.puter-theme-dark) .menu-button.focused {
background-color: #3a3a3a;
}
`;
}
@@ -105,6 +133,18 @@ class PuterMenubar extends PuterWebComponent {
if ( this._keyUpHandler ) {
document.removeEventListener('keyup', this._keyUpHandler, true);
}
if ( this._docPointerDownHandler ) {
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
}
if ( this._docFocusInHandler ) {
document.removeEventListener('focusin', this._docFocusInHandler, true);
}
if ( this._winBlurHandler ) {
window.removeEventListener('blur', this._winBlurHandler);
}
if ( this._mouseMoveHandler ) {
document.removeEventListener('mousemove', this._mouseMoveHandler);
}
const buttons = this.$$('.menu-button');
buttons.forEach((btn) => {
@@ -114,6 +154,14 @@ class PuterMenubar extends PuterWebComponent {
btn.addEventListener('click', (e) => {
e.stopPropagation();
// The dropdown closes on outside pointerdown, which fires
// before this click. If the user is pressing the same button
// that just closed, treat the press as a toggle-close —
// don't reopen on the trailing click.
if ( this._suppressClickFor === btn ) {
this._suppressClickFor = null;
return;
}
this.#focusedIndex = index;
this.#menubarActive = true;
if ( this.#activeButtonEl === btn ) {
@@ -124,19 +172,91 @@ class PuterMenubar extends PuterWebComponent {
this._openDropdown(btn, item);
});
// Hover-switch when a dropdown is already open
// JS-managed hover. Adding .hovered while clearing any keyboard
// .focused guarantees only one button highlights at a time, no
// matter the source.
btn.addEventListener('mouseenter', () => {
btn.classList.add('hovered');
// Mouse takes over: clear keyboard focus from everywhere
// and drop any lingering DOM :focus on a sibling button.
if ( this.#focusedIndex !== null ) {
this.#focusedIndex = null;
this._renderButtonFocus();
}
const root = this.shadowRoot;
const active = root && root.activeElement;
if ( active && active !== btn && active.classList.contains('menu-button') ) {
active.blur();
}
this._setKeyboardNav(false);
// Hover-switch when a dropdown is already open
if ( this.#activeDropdown && this.#activeButtonEl !== btn ) {
this.#focusedIndex = index;
this._openDropdown(btn, item);
}
});
btn.addEventListener('mouseleave', () => {
btn.classList.remove('hovered');
});
});
this._keyHandler = (e) => this._onGlobalKeyDown(e);
this._keyUpHandler = (e) => this._onGlobalKeyUp(e);
document.addEventListener('keydown', this._keyHandler, true);
document.addEventListener('keyup', this._keyUpHandler, true);
// Capture-phase pointerdown on document. The open context menu also
// listens in capture for outside-pointerdown to close itself; that
// listener registers later (when the dropdown opens) so ours runs
// first. If the press lands on the currently-active button, mark it
// so the trailing click — which would otherwise reopen the just-closed
// dropdown — is treated as a toggle-close. Uses composedPath because
// the button lives inside this shadow root.
this._docPointerDownHandler = (e) => {
const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
if ( this.#activeButtonEl && path.includes(this.#activeButtonEl) ) {
this._suppressClickFor = this.#activeButtonEl;
clearTimeout(this._suppressClickTimer);
this._suppressClickTimer = setTimeout(() => {
this._suppressClickFor = null;
}, 400);
}
// If the click lands fully outside the menubar host, drop any
// residual keyboard state. Without this, an Alt-activated menubar
// would keep intercepting arrow keys typed into other inputs.
// When a dropdown is open, its own close event will deactivate
// the menubar — leave that path alone here.
if ( this.#menubarActive && ! this.#activeDropdown && ! path.includes(this) ) {
this._deactivateMenubar();
}
};
document.addEventListener('pointerdown', this._docPointerDownHandler, true);
// Focus moving to an element outside the menubar (e.g. a text input)
// should also drop menubar state so global key handling stops.
this._docFocusInHandler = (e) => {
if ( ! this.#menubarActive ) return;
if ( this.#activeDropdown ) return;
const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
if ( ! path.includes(this) ) {
this._deactivateMenubar();
}
};
document.addEventListener('focusin', this._docFocusInHandler, true);
// Window losing focus (alt-tab, devtools, etc.) — reset state.
this._winBlurHandler = () => {
if ( this.#menubarActive && ! this.#activeDropdown ) {
this._deactivateMenubar();
}
this.#altDown = false;
this.#altConsumed = false;
};
window.addEventListener('blur', this._winBlurHandler);
// Once the user actually moves the mouse, exit keyboard-nav mode so
// :hover styling on menubar buttons works normally again.
this._mouseMoveHandler = () => this._setKeyboardNav(false);
document.addEventListener('mousemove', this._mouseMoveHandler);
}
_onGlobalKeyDown (e) {
@@ -167,10 +287,10 @@ class PuterMenubar extends PuterWebComponent {
switch ( e.key ) {
case 'ArrowRight':
this._moveButtonFocus(+1);
this._moveButtonFocus(+1, { openDropdown: true });
break;
case 'ArrowLeft':
this._moveButtonFocus(-1);
this._moveButtonFocus(-1, { openDropdown: true });
break;
case 'ArrowDown':
case 'Enter':
@@ -213,12 +333,14 @@ class PuterMenubar extends PuterWebComponent {
this.#menubarActive = true;
this.#focusedIndex = 0;
this._renderButtonFocus();
this._setKeyboardNav(true);
}
_deactivateMenubar () {
this.#menubarActive = false;
this.#focusedIndex = null;
this._renderButtonFocus();
this._setKeyboardNav(false);
}
_renderButtonFocus () {
@@ -228,19 +350,35 @@ class PuterMenubar extends PuterWebComponent {
});
}
_moveButtonFocus (delta, { swapDropdown = true } = {}) {
_setKeyboardNav (on) {
const menubar = this.$('.menubar');
if ( menubar ) menubar.classList.toggle('keyboard-nav', on);
}
_moveButtonFocus (delta, { swapDropdown = true, openDropdown = false } = {}) {
if ( ! this.#items.length ) return;
const n = this.#items.length;
const cur = this.#focusedIndex == null ? (delta > 0 ? -1 : 0) : this.#focusedIndex;
const next = (cur + delta + n) % n;
this.#focusedIndex = next;
this._renderButtonFocus();
this._setKeyboardNav(true);
const btn = this._buttonEl(next);
const item = this.#items[next];
if ( ! btn || ! item ) return;
// If a dropdown is already open, swap to the new button's dropdown
if ( swapDropdown && this.#activeDropdown ) {
const btn = this._buttonEl(next);
const item = this.#items[next];
if ( btn && item ) this._openDropdown(btn, item);
this._openDropdown(btn, item);
return;
}
// Arrow-nav at the menubar level opens the focused button's dropdown
// but does NOT pre-focus the first item — let the user press ArrowDown
// to enter the menu.
if ( openDropdown ) {
this._openFocusedButton(false);
}
}
@@ -260,6 +398,9 @@ class PuterMenubar extends PuterWebComponent {
if ( dd && typeof dd._focusableIndices === 'function' ) {
const f = dd._focusableIndices();
if ( f.length ) dd._setFocusIndex(f[0]);
if ( typeof dd._setKeyboardNav === 'function' ) {
dd._setKeyboardNav(true);
}
}
});
}
@@ -280,6 +421,9 @@ class PuterMenubar extends PuterWebComponent {
const rect = buttonEl.getBoundingClientRect();
const dropdown = document.createElement('puter-context-menu');
dropdown.setAttribute('data-submenu', ''); // skip mobile sheet behavior
// Forward any forced theme so the dropdown paints in the same theme.
const themeAttr = this.getAttribute('theme');
if ( themeAttr ) dropdown.setAttribute('theme', themeAttr);
dropdown.items = item.items;
dropdown.setAttribute('x', String(rect.left));
dropdown.setAttribute('y', String(rect.bottom));
@@ -290,19 +434,32 @@ class PuterMenubar extends PuterWebComponent {
this._deactivateMenubar();
});
dropdown.addEventListener('close', () => {
// The context menu closes itself on outside click; sync our state
// The context menu closes itself on outside click / Escape /
// selection. Sync our state and fully deactivate the menubar so
// a stray arrow / Enter / Space keypress doesn't re-open it.
if ( this.#activeDropdown === dropdown ) {
buttonEl.classList.remove('active');
this.#activeDropdown = null;
this.#activeButtonEl = null;
this._deactivateMenubar();
}
});
// Keyboard navigate request bubbling from the context menu
// Keyboard navigate request bubbling from the context menu.
// Arrow-left/right closes the current dropdown and opens the adjacent
// button's dropdown, mirroring menubar-level arrow nav.
// Arrow-up at the dropdown's first item closes it and returns
// focus to the same menubar button (which can re-open with ArrowDown).
dropdown.addEventListener('puter-menu-navigate', (e) => {
if ( ! e.detail ) return;
if ( e.detail.direction === 'up' ) {
this._closeDropdown();
this._renderButtonFocus();
this._setKeyboardNav(true);
return;
}
const delta = e.detail.direction === 'right' ? +1 : -1;
this._moveButtonFocus(delta, { swapDropdown: false });
this._openFocusedButton(true);
this._closeDropdown();
this._moveButtonFocus(delta, { swapDropdown: false, openDropdown: true });
});
document.body.appendChild(dropdown);
@@ -324,13 +481,28 @@ class PuterMenubar extends PuterWebComponent {
}
disconnectedCallback () {
super.disconnectedCallback();
this._closeDropdown();
clearTimeout(this._suppressClickTimer);
this._suppressClickFor = null;
if ( this._keyHandler ) {
document.removeEventListener('keydown', this._keyHandler, true);
}
if ( this._keyUpHandler ) {
document.removeEventListener('keyup', this._keyUpHandler, true);
}
if ( this._docPointerDownHandler ) {
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
}
if ( this._docFocusInHandler ) {
document.removeEventListener('focusin', this._docFocusInHandler, true);
}
if ( this._winBlurHandler ) {
window.removeEventListener('blur', this._winBlurHandler);
}
if ( this._mouseMoveHandler ) {
document.removeEventListener('mousemove', this._mouseMoveHandler);
}
}
_escapeHTML (str) {
@@ -175,6 +175,29 @@ class PuterNotification extends PuterWebComponent {
width: auto;
}
}
:host(.puter-theme-dark) .notification {
background: #2d2d2dcd;
border-color: #3a3a3a;
box-shadow: 0px 0px 17px -6px #000;
}
:host(.puter-theme-dark) .close-btn {
background: #3a3a3a;
color: #ccc;
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
}
:host(.puter-theme-dark) .close-btn:hover {
background: #4a4a4a;
color: #fff;
}
:host(.puter-theme-dark) .icon-area {
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
}
:host(.puter-theme-dark) .title {
color: #e6e6e6;
}
:host(.puter-theme-dark) .text {
color: #b0b0b0;
}
`;
}
@@ -255,6 +278,7 @@ class PuterNotification extends PuterWebComponent {
}
disconnectedCallback () {
super.disconnectedCallback();
if ( this._dismissTimer ) clearTimeout(this._dismissTimer);
const idx = activeNotifications.indexOf(this);
if ( idx !== -1 ) activeNotifications.splice(idx, 1);
+133 -7
View File
@@ -6,6 +6,8 @@
<script src="./fs.test.js"></script>
<script src="./ai.test.js"></script>
<script src="./txt2speech.test.js"></script>
<script src="./txt2img.test.js"></script>
<script src="./txt2vid.test.js"></script>
<style>
body {
font-family: Arial, sans-serif;
@@ -701,6 +703,36 @@
</div>`);
}
$('#tests').append('<h2><label><input type="checkbox" id="txt2imgTests-group"> Text-to-Image</label></h2>');
for (let i = 0; i < txt2imgTests.length; i++) {
const testInfo = getTestInfo(txt2imgTests[i]);
$('#tests').append(`<div class="test-container" id="txt2imgTests-container-${i}">
<div class="test-checkbox-container">
<input type="checkbox" class="test-checkbox txt2imgTests-checkbox" id="txt2imgTests${i}">
<label for="txt2imgTests${i}">
<div class="test-name">${testInfo.name}</div>
<div class="test-description">${testInfo.description}</div>
</label><br>
<button class="test-run-button" onclick="runSingleTest('txt2img', ${i})">Run Test</button>
</div>
</div>`);
}
$('#tests').append('<h2><label><input type="checkbox" id="txt2vidTests-group"> Text-to-Video</label></h2>');
for (let i = 0; i < txt2vidTests.length; i++) {
const testInfo = getTestInfo(txt2vidTests[i]);
$('#tests').append(`<div class="test-container" id="txt2vidTests-container-${i}">
<div class="test-checkbox-container">
<input type="checkbox" class="test-checkbox txt2vidTests-checkbox" id="txt2vidTests${i}">
<label for="txt2vidTests${i}">
<div class="test-name">${testInfo.name}</div>
<div class="test-description">${testInfo.description}</div>
</label><br>
<button class="test-run-button" onclick="runSingleTest('txt2vid', ${i})">Run Test</button>
</div>
</div>`);
}
// Add event listeners for group checkboxes
$('#fsTests-group').change(function() {
const isChecked = $(this).prop('checked');
@@ -722,6 +754,16 @@
$('.txt2speechTests-checkbox').prop('checked', isChecked);
});
$('#txt2imgTests-group').change(function() {
const isChecked = $(this).prop('checked');
$('.txt2imgTests-checkbox').prop('checked', isChecked);
});
$('#txt2vidTests-group').change(function() {
const isChecked = $(this).prop('checked');
$('.txt2vidTests-checkbox').prop('checked', isChecked);
});
// Add event listeners for individual checkboxes to update group checkbox state
$(document).on('change', '.fsTests-checkbox', function() {
const totalFsTests = $('.fsTests-checkbox').length;
@@ -765,7 +807,7 @@
$(document).on('change', '.txt2speechTests-checkbox', function() {
const totalTxt2speechTests = $('.txt2speechTests-checkbox').length;
const checkedTxt2speechTests = $('.txt2speechTests-checkbox:checked').length;
if (checkedTxt2speechTests === 0) {
$('#txt2speechTests-group').prop('checked', false).prop('indeterminate', false);
} else if (checkedTxt2speechTests === totalTxt2speechTests) {
@@ -775,6 +817,32 @@
}
});
$(document).on('change', '.txt2imgTests-checkbox', function() {
const totalTxt2imgTests = $('.txt2imgTests-checkbox').length;
const checkedTxt2imgTests = $('.txt2imgTests-checkbox:checked').length;
if (checkedTxt2imgTests === 0) {
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', false);
} else if (checkedTxt2imgTests === totalTxt2imgTests) {
$('#txt2imgTests-group').prop('checked', true).prop('indeterminate', false);
} else {
$('#txt2imgTests-group').prop('checked', false).prop('indeterminate', true);
}
});
$(document).on('change', '.txt2vidTests-checkbox', function() {
const totalTxt2vidTests = $('.txt2vidTests-checkbox').length;
const checkedTxt2vidTests = $('.txt2vidTests-checkbox:checked').length;
if (checkedTxt2vidTests === 0) {
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', false);
} else if (checkedTxt2vidTests === totalTxt2vidTests) {
$('#txt2vidTests-group').prop('checked', true).prop('indeterminate', false);
} else {
$('#txt2vidTests-group').prop('checked', false).prop('indeterminate', true);
}
});
window.assert = function(condition, message) {
if (!condition) {
throw new Error(message || "Assertion failed");
@@ -786,7 +854,9 @@
'fs': fsTests,
'kv': kvTests,
'ai': aiTests,
'txt2speech': txt2speechTests
'txt2speech': txt2speechTests,
'txt2img': txt2imgTests,
'txt2vid': txt2vidTests
};
const tests = testSuites[testType];
@@ -956,15 +1026,71 @@
$(`#txt2speechTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
testProgress.failed++;
}
testProgress.completed++;
updateProgressPanel();
// Small delay to make progress visible
await delay(100);
}
}
for (let i = 0; i < txt2imgTests.length; i++) {
if (document.getElementById(`txt2imgTests${i}`).checked) {
const testInfo = getTestInfo(txt2imgTests[i]);
testProgress.currentTest = `Text-to-Image: ${testInfo.name}`;
updateProgressPanel();
try{
await executeTest(txt2imgTests[i]);
$(`#txt2imgTests-container-${i}`).css('background-color', '#85e085');
testProgress.passed++;
} catch (e) {
console.error('Txt2Img Test failed:', testInfo.name, e);
$(`#txt2imgTests-container-${i}`).css('background-color', '#ff8484');
let errorMessage = e.message || e.toString();
if (e.originalError) {
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
}
$(`#txt2imgTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
testProgress.failed++;
}
testProgress.completed++;
updateProgressPanel();
await delay(100);
}
}
for (let i = 0; i < txt2vidTests.length; i++) {
if (document.getElementById(`txt2vidTests${i}`).checked) {
const testInfo = getTestInfo(txt2vidTests[i]);
testProgress.currentTest = `Text-to-Video: ${testInfo.name}`;
updateProgressPanel();
try{
await executeTest(txt2vidTests[i]);
$(`#txt2vidTests-container-${i}`).css('background-color', '#85e085');
testProgress.passed++;
} catch (e) {
console.error('Txt2Vid Test failed:', testInfo.name, e);
$(`#txt2vidTests-container-${i}`).css('background-color', '#ff8484');
let errorMessage = e.message || e.toString();
if (e.originalError) {
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
}
$(`#txt2vidTests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
testProgress.failed++;
}
testProgress.completed++;
updateProgressPanel();
await delay(100);
}
}
// Show completion message
testProgress.currentTest = `Complete! ${testProgress.passed} passed, ${testProgress.failed} failed`;
updateProgressPanel();
@@ -996,7 +1122,7 @@
$('#master-checkbox').change(function() {
const isChecked = $(this).prop('checked');
$('.test-checkbox').prop('checked', isChecked);
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').prop('checked', isChecked);
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').prop('checked', isChecked);
// Update the counter display
updateMasterCheckboxState();
});
@@ -1024,7 +1150,7 @@
});
// Update master checkbox state when group checkboxes change
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group').change(function() {
$('#fsTests-group, #kvTests-group, #aiTests-group, #txt2speechTests-group, #txt2imgTests-group, #txt2vidTests-group').change(function() {
updateMasterCheckboxState();
});
+245
View File
@@ -0,0 +1,245 @@
/* eslint-disable */
// TODO: Make these more compatible with eslint
// Core test functions for txt2img functionality
const testTxt2ImgBasicCore = async function() {
const result = await puter.ai.txt2img("A red circle on a white background", true);
assert(result instanceof Image, "txt2img should return an Image object");
assert(result !== null, "txt2img should not return null");
assert(typeof result.src === 'string', "result should have src property as string");
assert(result.src.length > 0, "src should not be empty");
const isValidUrl = result.src.startsWith('blob:') ||
result.src.startsWith('data:') ||
result.src.startsWith('http:') ||
result.src.startsWith('https:');
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
assert(typeof result.toString === 'function', "result should have toString method");
assert(typeof result.valueOf === 'function', "result should have valueOf method");
assert(result.toString() === result.src, "toString() should return src");
assert(result.valueOf() === result.src, "valueOf() should return src");
};
const testTxt2ImgWithOptionsCore = async function() {
const result = await puter.ai.txt2img("A blue square", {
test_mode: true,
});
assert(result instanceof Image, "txt2img with options should return an Image object");
assert(result !== null, "txt2img with options should not return null");
assert(typeof result.src === 'string', "result should have src property as string");
assert(result.src.length > 0, "src should not be empty");
assert(result.toString() === result.src, "toString() should return src");
assert(result.valueOf() === result.src, "valueOf() should return src");
};
const testTxt2ImgObjectSyntaxCore = async function() {
const result = await puter.ai.txt2img({
prompt: "A green triangle",
test_mode: true,
});
assert(result instanceof Image, "txt2img object syntax should return an Image object");
assert(result !== null, "txt2img object syntax should not return null");
assert(typeof result.src === 'string', "result should have src property as string");
assert(result.src.length > 0, "src should not be empty");
};
const testTxt2ImgPuterOutputPathCore = async function() {
const outputPath = `test_output_${Date.now()}.png`;
const result = await puter.ai.txt2img("A yellow star on black background", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result instanceof Image, "txt2img with puter_output_path should return an Image object");
assert(result !== null, "txt2img with puter_output_path should not return null");
assert(typeof result.src === 'string', "result should have src property as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written to the filesystem
const stat = await puter.fs.stat(outputPath);
assert(stat !== null && stat !== undefined, "file should exist at the output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(outputPath);
};
const testTxt2ImgPuterOutputPathAbsoluteCore = async function() {
const user = await puter.auth.getUser();
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.png`;
const result = await puter.ai.txt2img("A purple diamond", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result instanceof Image, "txt2img with absolute puter_output_path should return an Image");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written
const stat = await puter.fs.stat(outputPath);
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(outputPath);
};
const testTxt2ImgPuterOutputPathHomeTildeCore = async function() {
const outputPath = `~/test_output_tilde_${Date.now()}.png`;
const result = await puter.ai.txt2img("An orange hexagon", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result instanceof Image, "txt2img with ~ puter_output_path should return an Image");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written (resolve ~ for stat)
const user = await puter.auth.getUser();
const resolvedPath = outputPath.replace('~', `/${user.username}`);
const stat = await puter.fs.stat(resolvedPath);
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(resolvedPath);
};
const testTxt2ImgPuterOutputPathPermissionDeniedCore = async function() {
let caught = false;
let caughtError = null;
try {
await puter.ai.txt2img("A test image", {
test_mode: true,
puter_output_path: "/some_other_user/no_access/image.png",
});
} catch (error) {
caught = true;
caughtError = error;
}
assert(caught, "txt2img should throw when writing to a path without permission");
assert(caughtError !== null, "error should not be null");
// The error should contain the actual backend error, NOT the generic
// "Unexpected image response format" message
const errMsg = typeof caughtError === 'string' ? caughtError
: caughtError?.error?.message ?? caughtError?.message ?? '';
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
assert(
!errMsg.includes('Unexpected image response format'),
`Should surface the real backend error, not the generic 'Unexpected image response format'. Got: ${errMsg}`
);
assert(
errCode !== 'invalid_image_response',
`Error code should not be 'invalid_image_response'. Got code: ${errCode}, message: ${errMsg}`
);
};
// Export test functions
window.txt2imgTests = [
{
name: "testTxt2ImgBasic",
description: "Test basic text-to-image generation with test mode and verify Image object structure",
test: async function() {
try {
await testTxt2ImgBasicCore();
pass("testTxt2ImgBasic passed");
} catch (error) {
fail("testTxt2ImgBasic failed:", error);
}
}
},
{
name: "testTxt2ImgWithOptions",
description: "Test txt2img with prompt string and options object",
test: async function() {
try {
await testTxt2ImgWithOptionsCore();
pass("testTxt2ImgWithOptions passed");
} catch (error) {
fail("testTxt2ImgWithOptions failed:", error);
}
}
},
{
name: "testTxt2ImgObjectSyntax",
description: "Test txt2img with single options object containing prompt",
test: async function() {
try {
await testTxt2ImgObjectSyntaxCore();
pass("testTxt2ImgObjectSyntax passed");
} catch (error) {
fail("testTxt2ImgObjectSyntax failed:", error);
}
}
},
{
name: "testTxt2ImgPuterOutputPath",
description: "Test that puter_output_path writes the generated image to the Puter filesystem (relative path)",
test: async function() {
try {
await testTxt2ImgPuterOutputPathCore();
pass("testTxt2ImgPuterOutputPath passed");
} catch (error) {
fail("testTxt2ImgPuterOutputPath failed:", error);
}
}
},
{
name: "testTxt2ImgPuterOutputPathAbsolute",
description: "Test puter_output_path with an absolute path",
test: async function() {
try {
await testTxt2ImgPuterOutputPathAbsoluteCore();
pass("testTxt2ImgPuterOutputPathAbsolute passed");
} catch (error) {
fail("testTxt2ImgPuterOutputPathAbsolute failed:", error);
}
}
},
{
name: "testTxt2ImgPuterOutputPathHomeTilde",
description: "Test puter_output_path with a ~/... home-relative path",
test: async function() {
try {
await testTxt2ImgPuterOutputPathHomeTildeCore();
pass("testTxt2ImgPuterOutputPathHomeTilde passed");
} catch (error) {
fail("testTxt2ImgPuterOutputPathHomeTilde failed:", error);
}
}
},
{
name: "testTxt2ImgPuterOutputPathPermissionDenied",
description: "Test that writing to a path without permission surfaces the real backend error, not a generic one",
test: async function() {
try {
await testTxt2ImgPuterOutputPathPermissionDeniedCore();
pass("testTxt2ImgPuterOutputPathPermissionDenied passed");
} catch (error) {
fail("testTxt2ImgPuterOutputPathPermissionDenied failed:", error);
}
}
},
];
+239
View File
@@ -0,0 +1,239 @@
/* eslint-disable */
// TODO: Make these more compatible with eslint
// Core test functions for txt2vid functionality
const testTxt2VidBasicCore = async function() {
const result = await puter.ai.txt2vid("A sunrise over the ocean", true);
assert(result !== null, "txt2vid should not return null");
assert(typeof result === 'object', "txt2vid should return an object");
// Should be a video element (or object with video-like properties in non-DOM envs)
assert(typeof result.src === 'string', "result should have src property as string");
assert(result.src.length > 0, "src should not be empty");
const isValidUrl = result.src.startsWith('blob:') ||
result.src.startsWith('data:') ||
result.src.startsWith('http:') ||
result.src.startsWith('https:');
assert(isValidUrl, `src should be a valid URL, got: ${result.src.substring(0, 80)}`);
assert(typeof result.toString === 'function', "result should have toString method");
assert(typeof result.valueOf === 'function', "result should have valueOf method");
assert(result.toString() === result.src, "toString() should return src");
assert(result.valueOf() === result.src, "valueOf() should return src");
};
const testTxt2VidWithOptionsCore = async function() {
const result = await puter.ai.txt2vid("A cat walking through grass", {
test_mode: true,
});
assert(result !== null, "txt2vid with options should not return null");
assert(typeof result === 'object', "txt2vid with options should return an object");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
assert(result.toString() === result.src, "toString() should return src");
assert(result.valueOf() === result.src, "valueOf() should return src");
};
const testTxt2VidObjectSyntaxCore = async function() {
const result = await puter.ai.txt2vid({
prompt: "Clouds drifting across a blue sky",
test_mode: true,
});
assert(result !== null, "txt2vid object syntax should not return null");
assert(typeof result === 'object', "txt2vid object syntax should return an object");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
};
const testTxt2VidPuterOutputPathCore = async function() {
const outputPath = `test_output_${Date.now()}.mp4`;
const result = await puter.ai.txt2vid("A ball rolling down a hill", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result !== null, "txt2vid with puter_output_path should not return null");
assert(typeof result === 'object', "txt2vid with puter_output_path should return an object");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written to the filesystem
const stat = await puter.fs.stat(outputPath);
assert(stat !== null && stat !== undefined, "file should exist at the output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(outputPath);
};
const testTxt2VidPuterOutputPathAbsoluteCore = async function() {
const user = await puter.auth.getUser();
const outputPath = `/${user.username}/test_output_abs_${Date.now()}.mp4`;
const result = await puter.ai.txt2vid("Rain falling on a window", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result !== null, "txt2vid with absolute puter_output_path should not return null");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written
const stat = await puter.fs.stat(outputPath);
assert(stat !== null && stat !== undefined, "file should exist at absolute output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(outputPath);
};
const testTxt2VidPuterOutputPathHomeTildeCore = async function() {
const outputPath = `~/test_output_tilde_${Date.now()}.mp4`;
const result = await puter.ai.txt2vid("Waves crashing on a beach", {
test_mode: true,
puter_output_path: outputPath,
});
assert(result !== null, "txt2vid with ~ puter_output_path should not return null");
assert(typeof result.src === 'string', "result should have src as string");
assert(result.src.length > 0, "src should not be empty");
// Verify the file was written (resolve ~ for stat)
const user = await puter.auth.getUser();
const resolvedPath = outputPath.replace('~', `/${user.username}`);
const stat = await puter.fs.stat(resolvedPath);
assert(stat !== null && stat !== undefined, "file should exist at home-relative output path");
assert(stat.size > 0, "written file should not be empty");
// Clean up
await puter.fs.delete(resolvedPath);
};
const testTxt2VidPuterOutputPathPermissionDeniedCore = async function() {
let caught = false;
let caughtError = null;
try {
await puter.ai.txt2vid("A test video", {
test_mode: true,
puter_output_path: "/some_other_user/no_access/video.mp4",
});
} catch (error) {
caught = true;
caughtError = error;
}
assert(caught, "txt2vid should throw when writing to a path without permission");
assert(caughtError !== null, "error should not be null");
// The error should contain the actual backend error, NOT a generic message
const errMsg = typeof caughtError === 'string' ? caughtError
: caughtError?.error?.message ?? caughtError?.message ?? '';
const errCode = caughtError?.error?.code ?? caughtError?.code ?? '';
assert(
errCode !== 'invalid_video_response',
`Error code should not be generic. Got code: ${errCode}, message: ${errMsg}`
);
};
// Export test functions
window.txt2vidTests = [
{
name: "testTxt2VidBasic",
description: "Test basic text-to-video generation with test mode and verify video element structure",
test: async function() {
try {
await testTxt2VidBasicCore();
pass("testTxt2VidBasic passed");
} catch (error) {
fail("testTxt2VidBasic failed:", error);
}
}
},
{
name: "testTxt2VidWithOptions",
description: "Test txt2vid with prompt string and options object",
test: async function() {
try {
await testTxt2VidWithOptionsCore();
pass("testTxt2VidWithOptions passed");
} catch (error) {
fail("testTxt2VidWithOptions failed:", error);
}
}
},
{
name: "testTxt2VidObjectSyntax",
description: "Test txt2vid with single options object containing prompt",
test: async function() {
try {
await testTxt2VidObjectSyntaxCore();
pass("testTxt2VidObjectSyntax passed");
} catch (error) {
fail("testTxt2VidObjectSyntax failed:", error);
}
}
},
{
name: "testTxt2VidPuterOutputPath",
description: "Test that puter_output_path writes the generated video to the Puter filesystem (relative path)",
test: async function() {
try {
await testTxt2VidPuterOutputPathCore();
pass("testTxt2VidPuterOutputPath passed");
} catch (error) {
fail("testTxt2VidPuterOutputPath failed:", error);
}
}
},
{
name: "testTxt2VidPuterOutputPathAbsolute",
description: "Test puter_output_path with an absolute path",
test: async function() {
try {
await testTxt2VidPuterOutputPathAbsoluteCore();
pass("testTxt2VidPuterOutputPathAbsolute passed");
} catch (error) {
fail("testTxt2VidPuterOutputPathAbsolute failed:", error);
}
}
},
{
name: "testTxt2VidPuterOutputPathHomeTilde",
description: "Test puter_output_path with a ~/... home-relative path",
test: async function() {
try {
await testTxt2VidPuterOutputPathHomeTildeCore();
pass("testTxt2VidPuterOutputPathHomeTilde passed");
} catch (error) {
fail("testTxt2VidPuterOutputPathHomeTilde failed:", error);
}
}
},
{
name: "testTxt2VidPuterOutputPathPermissionDenied",
description: "Test that writing to a path without permission surfaces the real backend error",
test: async function() {
try {
await testTxt2VidPuterOutputPathPermissionDeniedCore();
pass("testTxt2VidPuterOutputPathPermissionDenied passed");
} catch (error) {
fail("testTxt2VidPuterOutputPathPermissionDenied failed:", error);
}
}
},
];
+17
View File
@@ -0,0 +1,17 @@
# Copy this file to `.env` (in the same folder) and fill in your local values.
# `.env` is gitignored — never commit real credentials.
# Required: admin password from your local Puter.
# When you run `npm start` from the monorepo root, Puter prints something like:
# ************************************************************
# * Your default login credentials are:
# * Username: admin
# * Password: <hex string>
# ************************************************************
# Copy that password here.
PUTER_ADMIN_PASSWORD=
# Optional overrides (defaults shown).
# PUTER_ADMIN_USERNAME=admin
# PUTER_TEST_ORIGIN=http://puter.localhost:4100
# PUTER_TEST_FIXTURE_ORIGIN=http://localhost:8080
+1
View File
@@ -0,0 +1 @@
.auth/
+16
View File
@@ -0,0 +1,16 @@
# puter-js end-to-end tests
See **[../../TESTING.md](../../TESTING.md)** for the full guide: setup, how to set the admin password, how to run the tests, how it all works, and how to add new tests.
## TL;DR
```sh
# one-time setup
cp tests/e2e/.env.example tests/e2e/.env
$EDITOR tests/e2e/.env # set PUTER_ADMIN_PASSWORD from the npm start banner
# run
npm run test:e2e # headless
npm run test:e2e:headed # watch the browser
npm run test:e2e:record # save video for every test
```
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: menubar + contextMenu</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js test fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="set-menubar-btn" disabled>Set Menubar</button>
<button id="ctx-trigger" disabled>Show Context Menu</button>
<div id="log" data-testid="log"></div>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
function ready() {
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
document.getElementById('set-menubar-btn').disabled = false;
document.getElementById('ctx-trigger').disabled = false;
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
document.getElementById('set-menubar-btn').addEventListener('click', () => {
puter.ui.setMenubar({
items: [
{ label: 'TestFile', action: () => log('menubar:TestFile') },
{ label: 'TestEdit', items: [
{ label: 'TestUndo', action: () => log('menubar:TestUndo') },
{ label: 'TestRedo', action: () => log('menubar:TestRedo') },
]},
],
});
log('menubar:set');
});
document.getElementById('ctx-trigger').addEventListener('click', () => {
puter.ui.contextMenu({
items: [
{ label: 'CtxAlpha', action: () => log('ctx:CtxAlpha') },
{ label: 'CtxBeta', action: () => log('ctx:CtxBeta') },
'-',
{ label: 'CtxGamma', action: () => log('ctx:CtxGamma') },
],
});
log('ctx:opened');
});
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { chromium } from '@playwright/test';
import { existsSync } from 'node:fs';
import { readFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STORAGE_STATE_PATH = path.join(__dirname, '.auth', 'state.json');
const ENV_FILE_PATH = path.join(__dirname, '.env');
const CACHE_TTL_MS = 1000 * 60 * 60 * 24;
// Tiny dotenv loader. Loads tests/e2e/.env into process.env if it exists.
// Existing process.env wins, so CLI overrides still work.
async function loadDotEnv (filePath) {
if ( ! existsSync(filePath) ) return;
const text = await readFile(filePath, 'utf8');
for ( const rawLine of text.split('\n') ) {
const line = rawLine.trim();
if ( !line || line.startsWith('#') ) continue;
const eq = line.indexOf('=');
if ( eq < 0 ) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) {
value = value.slice(1, -1);
}
if ( ! (key in process.env) ) process.env[key] = value;
}
}
async function isCachedStateFresh () {
if ( ! existsSync(STORAGE_STATE_PATH) ) return false;
if ( process.env.PUTER_TEST_RESET_AUTH === '1' ) return false;
try {
const s = await stat(STORAGE_STATE_PATH);
return (Date.now() - s.mtimeMs) < CACHE_TTL_MS;
} catch {
return false;
}
}
async function loginAsAdmin ({ origin, username, password }) {
// Puter rejects POSTs without an Origin header (403 Forbidden), so send
// one matching the target origin to satisfy its CSRF check.
const res = await fetch(`${origin}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': origin,
},
body: JSON.stringify({ username, password }),
});
if ( ! res.ok ) {
const text = await res.text().catch(() => '');
throw new Error(`POST /login → HTTP ${res.status}: ${text || '(empty body)'}`);
}
const data = await res.json();
if ( ! data.token ) {
throw new Error(`POST /login response had no token: ${JSON.stringify(data)}`);
}
return data.token;
}
export default async function globalSetup () {
await loadDotEnv(ENV_FILE_PATH);
const PUTER_ORIGIN = process.env.PUTER_TEST_ORIGIN || 'http://puter.localhost:4100';
const ADMIN_USERNAME = process.env.PUTER_ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.PUTER_ADMIN_PASSWORD || '';
await mkdir(path.dirname(STORAGE_STATE_PATH), { recursive: true });
if ( await isCachedStateFresh() ) {
console.log(`[e2e] Reusing cached auth state at ${STORAGE_STATE_PATH}`);
return;
}
if ( ! ADMIN_PASSWORD ) {
throw new Error(
'PUTER_ADMIN_PASSWORD is not set.\n' +
'Your local Puter prints the admin password at startup (look for "Password: <hex>" in the monorepo\'s npm start output).\n' +
'Set it in tests/e2e/.env (gitignored):\n' +
' cp tests/e2e/.env.example tests/e2e/.env\n' +
' # then edit tests/e2e/.env and set PUTER_ADMIN_PASSWORD\n' +
'Or pass it inline: PUTER_ADMIN_PASSWORD=<value> npm run test:e2e',
);
}
// 1) Direct API login → token (no browser, no /signup, no rate-limit risk).
const token = await loginAsAdmin({ origin: PUTER_ORIGIN, username: ADMIN_USERNAME, password: ADMIN_PASSWORD });
// 2) Open Puter with ?auth_token=<token>&api_origin=<origin> so initgui's
// auth_token flow runs the full setup (puter.setAuthToken,
// puter.setAPIOrigin, /session/sync-cookie, update_auth_data) and persists
// the API origin to localStorage.api_origin — otherwise the bundled SDK
// keeps its production default (https://api.puter.com) and apps.create
// hits the wrong server.
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// Probe the GUI's templated api_origin first so we can pass it through.
await page.goto(PUTER_ORIGIN);
const apiOrigin = await page.evaluate(() => window.api_origin || null);
if ( ! apiOrigin ) {
await browser.close();
throw new Error(`Could not determine api_origin from ${PUTER_ORIGIN} (window.api_origin was empty).`);
}
await page.goto(
`${PUTER_ORIGIN}/?auth_token=${encodeURIComponent(token)}` +
`&api_origin=${encodeURIComponent(apiOrigin)}`,
);
await page.waitForFunction(() => !!window.puter, null, { timeout: 60_000 });
await page.waitForFunction(
() => {
const ls = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
return !!(ls && lsApi && window.auth_token && window.puter?.authToken && window.puter?.APIOrigin === lsApi);
},
null,
{ timeout: 60_000 },
);
await context.storageState({ path: STORAGE_STATE_PATH });
await browser.close();
console.log(`[e2e] Logged in as "${ADMIN_USERNAME}", saved auth state to ${STORAGE_STATE_PATH}`);
}
+119
View File
@@ -0,0 +1,119 @@
import { randomUUID } from 'node:crypto';
const FIXTURE_ORIGIN = process.env.PUTER_TEST_FIXTURE_ORIGIN || 'http://localhost:8080';
const FIXTURE_PATH = '/tests/e2e/fixtures/menubar-contextmenu.html';
export const FIXTURE_URL = `${FIXTURE_ORIGIN}${FIXTURE_PATH}`;
const PUTER_READY_TIMEOUT = 60_000;
/**
* Waits for Puter desktop to be fully signed in (auto-temp-user creation done)
* and the puter.js SDK to be authenticated. Throws on timeout with a useful
* snapshot of why we think it's not ready.
*/
export async function waitForPuterReady (page) {
await page.waitForFunction(() => !!window.puter, null, { timeout: PUTER_READY_TIMEOUT });
// With storageState, sign-in should already be done. Wait for both auth
// and the API origin to be picked up by the SDK from localStorage.
try {
await page.waitForFunction(
() => {
const ls = (typeof localStorage !== 'undefined') ? localStorage.getItem('auth_token') : null;
const lsApi = (typeof localStorage !== 'undefined') ? localStorage.getItem('api_origin') : null;
return !!(
ls && lsApi &&
window.auth_token && window.puter?.authToken &&
window.puter?.APIOrigin === lsApi
);
},
null,
{ timeout: PUTER_READY_TIMEOUT },
);
} catch (e) {
const diag = await page.evaluate(() => ({
url: location.href,
puter: typeof window.puter,
puterAuthToken: !!window.puter?.authToken,
windowAuthToken: !!window.auth_token,
lsAuthToken: !!(typeof localStorage !== 'undefined' && localStorage.getItem('auth_token')),
firstVisitEver: window.first_visit_ever,
isAuth: typeof window.is_auth === 'function' ? window.is_auth() : null,
user: window.user ? { is_temp: window.user.is_temp, username: window.user.username } : null,
captchaModalVisible: document.querySelector('.captcha-modal') ? true : false,
}));
throw new Error(`waitForPuterReady timed out. Diagnostics: ${JSON.stringify(diag, null, 2)}`);
}
await page.waitForFunction(() => !!window.puter?.apps?.create, null, { timeout: 10_000 });
}
async function assertFixtureReachable (fixtureURL) {
let res;
try {
res = await fetch(fixtureURL, { method: 'GET' });
} catch (e) {
throw new Error(
`Fixture URL is unreachable: ${fixtureURL}\n` +
'→ Is the puter-js dev server running? From src/puter-js: `npm start`\n' +
`Underlying error: ${e?.message || e}`,
);
}
if ( ! res.ok ) {
throw new Error(`Fixture URL returned HTTP ${res.status}: ${fixtureURL}`);
}
}
export async function registerTestApp (page, { fixtureURL = FIXTURE_URL } = {}) {
await assertFixtureReachable(fixtureURL);
await page.goto('/');
await waitForPuterReady(page);
const appName = `puter-js-testing-${randomUUID().slice(0, 8)}`;
const result = await page.evaluate(
async ({ name, url }) => {
const ctx = {
APIOrigin: window.puter?.APIOrigin,
authTokenLen: window.puter?.authToken ? window.puter.authToken.length : 0,
api_origin: window.api_origin,
};
try {
const app = await window.puter.apps.create(name, url);
return { ok: true, app, ctx };
} catch (e) {
return { ok: false, error: String(e?.message || (typeof e === 'object' ? JSON.stringify(e) : e)), ctx };
}
},
{ name: appName, url: fixtureURL },
);
if ( ! result.ok ) {
throw new Error(`puter.apps.create failed: ${result.error}\nContext: ${JSON.stringify(result.ctx)}`);
}
return appName;
}
export async function deleteTestApp (page, appName) {
if ( ! appName ) return;
try {
await page.goto('/');
await waitForPuterReady(page);
await page.evaluate(async (name) => {
try {
await window.puter.apps.delete(name);
} catch {
}
}, appName);
} catch {
// Cleanup is best-effort.
}
}
export async function gotoTestApp (page, appName) {
await page.goto(`/app/${appName}`);
const appFrame = page.frameLocator('iframe.window-app-iframe').last();
await appFrame.locator('body.ready').waitFor({ timeout: 60_000 });
return appFrame;
}
@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp } from '../helpers/testApp.js';
test.describe('puter.ui.contextMenu (env=app)', () => {
test('renders context menu and fires action when item is clicked', async ({ page }) => {
const appName = await registerTestApp(page);
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#ctx-trigger').click();
const menu = page.locator('.context-menu:visible').last();
await expect(menu).toBeVisible();
const itemAlpha = menu.locator('.context-menu-item', { hasText: 'CtxAlpha' });
const itemBeta = menu.locator('.context-menu-item', { hasText: 'CtxBeta' });
await expect(itemAlpha).toBeVisible();
await expect(itemBeta).toBeVisible();
// This is the regression test for commit aa5e398e: on mobile viewports the
// sheet backdrop must sit BELOW the menu so taps reach the items. If the
// z-index regresses, the backdrop swallows this click and the action never
// fires, leaving the log empty.
await itemAlpha.click();
await expect(appFrame.locator('#log [data-entry="ctx:CtxAlpha"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
test('clicking outside the context menu dismisses it', async ({ page }) => {
const appName = await registerTestApp(page);
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#ctx-trigger').click();
const menu = page.locator('.context-menu:visible').last();
await expect(menu).toBeVisible();
// Click somewhere far from the menu.
await page.mouse.click(5, 5);
await expect(menu).toBeHidden();
} finally {
await deleteTestApp(page, appName);
}
});
});
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp } from '../helpers/testApp.js';
test.describe('puter.ui.setMenubar (env=app)', () => {
test('renders menubar items in Puter desktop and fires action on click', async ({ page }) => {
const appName = await registerTestApp(page);
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#set-menubar-btn').click();
// Menubar is rendered by Puter desktop in the parent frame, not the app iframe.
const menubar = page.locator('.window-menubar:visible');
await expect(menubar).toBeVisible();
const fileItem = menubar.locator('.window-menubar-item', { hasText: 'TestFile' });
const editItem = menubar.locator('.window-menubar-item', { hasText: 'TestEdit' });
await expect(fileItem).toBeVisible();
await expect(editItem).toBeVisible();
// Top-level item with a direct action: click should fire it.
await fileItem.click();
await expect(appFrame.locator('#log [data-entry="menubar:TestFile"]')).toBeVisible();
// Top-level item with subitems: click opens a dropdown context menu.
await editItem.click();
const dropdown = page.locator('.context-menu:visible').last();
await expect(dropdown).toBeVisible();
await expect(dropdown.locator('.context-menu-item', { hasText: 'TestUndo' })).toBeVisible();
await dropdown.locator('.context-menu-item', { hasText: 'TestUndo' }).click();
await expect(appFrame.locator('#log [data-entry="menubar:TestUndo"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
});
+2
View File
@@ -77,6 +77,7 @@ export interface Txt2ImgOptions {
disable_safety_checker?: boolean;
response_format?: string;
test_mode?: boolean;
puter_output_path?: string;
}
export interface Txt2VidOptions {
@@ -106,6 +107,7 @@ export interface Txt2VidOptions {
reference_images?: string[];
frame_images?: Array<{ input_image: string; frame: number }>;
metadata?: Record<string, unknown>;
puter_output_path?: string;
}
export interface Txt2SpeechOptions {