Compare commits

...
Author SHA1 Message Date
Daniel Salazar fd8d556cd5 Merge branch 'main' into refactor/ai-driver-layout 2026-11-13 23:05:37 -07:00
Daniel Salazar af01a554fe feat(gui): a top-up asked for from a page names the app it runs as (#4637)
The framed top-up passes the deployment's dialog the app the framing page's
origin resolves to, as the framed upgrade does. The dashboard link puter.js
opens when no frame can show carries the app in `topup_app`.
2026-11-13 23:00:39 -07:00
Nariman JelvehandClaude Opus 5.5 3e11e7dc79 refactor: keep folder sort and layout in puter.kv, drop /set_sort_by and /set_layout (#4642)
Explorer saved each folder's sort and layout through POST /set_sort_by and
/set_layout, which wrote them onto the folder's fsentries row. stat only
returns `layout`, so a saved sort was never read back.

The GUI now keeps them in the user's key-value store, one key per folder
uid (`folder_view:<uid>` holding `{ sort_by, sort_order, layout }`), via
helpers/folderViewPrefs.js. A change to one preference keeps the others.
A folder with nothing stored falls back to the layout its fsentry still
carries, then to the defaults, so existing layouts carry over.

refresh_item_container reads the folder's view after stat and re-sorts
items that went in under the previous sort; a view for a folder the
window has left is ignored. update_window_path no longer applies sort and
layout itself, since the refresh it starts does.

With no callers left, DesktopController and its two routes are removed.
The fsentries columns and stat's `layout` field stay.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 21:34:37 -07:00
Nariman JelvehandClaude Opus 5.5 5b1b5b8a7a fix(puter-js): apps launched from desktop.puter.com take its messages (#4640)
In an app, puter.js trusted only https://puter.com as the GUI origin, so
every desktop message from desktop.puter.com failed the origin check and
was dropped. The desktop waits for the app's answer to windowWillClose,
so app windows there never closed; itemsOpened, drag-and-drop, focus,
theme and locale changes were lost the same way, and a reauth request
was posted to the wrong origin.

An app now takes its GUI origin from the launch URL's `puter.origin`,
but only when that is one of Puter's own desktops (exactly
https://puter.com or https://desktop.puter.com); anything else keeps
https://puter.com, so a page that frames an app still can't drive it.
An explicit PUTER_ORIGIN still wins, and a top-level page never reads it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 20:51:33 -07:00
Daniel Salazar b2eb8ed895 feat(auth): a first-party page served on its own origin signs in with a session of its own (#4635)
A deployment can list origins of its own pages in
`first_party_session_origins`. When such a page asks the GUI to sign it in,
the sign-in hands it a GUI token on a `web` session of its own, parented to
the user's sign-in, instead of an app token.

The session is minted by POST /auth/first-party-session, which only the GUI's
own page script may call, and only as the user's sign-in itself: app tokens,
access tokens and a session minted this way are refused. It never gets a
session cookie, ends with the sign-in it was handed out under (revoking either
takes the tokens minted under the page along), is not counted as a sign-in,
and stays up with its sign-in when all other sessions are revoked.

The sessions list shows it under its sign-in, titled with the page's host and
badged as a site session.
2026-11-13 17:47:57 -07:00
Daniel Salazar d1cbfb9fb1 fix(puter-js): a godmode app opened in its own tab stays signed in across a reload (#4632)
A tab the desktop opened for an app boots the SDK as a top-level page, where
a session-type token was kept in memory only. Once the desktop hands such a
tab its launch token, the SDK now keeps it in sessionStorage, as a framed app
does, and a reload of that tab reads it back before any stored app token.

A page that wasn't handed a token this way still persists no session token.
2026-11-13 15:46:17 -07:00
Neal ShahandClaude Opus 5.5 ac44de819d fix(hosting): choose the site and subdomain from Host, not X-Forwarded-Host (#4459)
With trust proxy on, express derives req.hostname and req.subdomains from
X-Forwarded-Host, which any client can send, while host validation reads the
raw Host. A request could serve one hosted site's files on the main domain,
or reach the API on a hosting host. The header is now dropped before routing.

Fixes PUT-2272

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 14:56:16 -05:00
Daniel Salazar 4ad53899da feat(auth): debug app sessions that act as a user in one app (#4629)
Adds a `debug_app` session kind and AuthService.createDebugAppToken, which
mints an app-under-user token on its own session row instead of the user's
app session. The row has a hard expiry that activity never slides, its calls
record no user activity, it is hidden from the user's session list, and a
password reset revokes it. SessionStore.deleteExpiredDebugAppSessions purges
expired rows. Migrations add the kind to the sessions constraint on sqlite,
mysql and postgres.
2026-11-13 11:19:08 -07:00
Daniel Salazar c253b7e05e feat(auth): signups record the request's TLS fingerprint when a fronting proxy supplies one (#4622)
`tlsFingerprintHeader` names a request header that a TLS-terminating proxy
sets to the client's TLS fingerprint. When set, the value is exposed as
`req.tlsFingerprint`, stored on the new `user.signup_tls_fingerprint`
column, passed to `puter.signup.validate`, and forwarded to the phone
verification provider when it is a JA4.
2026-11-13 10:41:57 -07:00
647995133e fix(http): responses stay under Node's close-listener limit, starting with WebDAV GET (#4621)
* fix(webdav): GET pipes the file without stacking close listeners on the response

stream.pipeline put seven close listeners on the response. With metering,
compression, the concurrency gate and request tracing on top, every WebDAV
download crossed Node's ten-listener warning. GET now uses pipeToResponse,
like the other file download routes.

* fix(drivers): a streamed result stops its source on unpipe instead of its own close listener

pipe already unpipes when the response finishes, closes or fails, so the
extra close listener only spent part of the response's listener budget. The
routes that hold a response open now have a test that keeps each within that
budget.

* refactor(http): everything that waits on a response's close shares one listener

Egress metering, the concurrency gate, the large-body budget, route
lifecycle events, driver call slots, disconnect aborts and SSE writers each
put their own close listener on the response. They now register callbacks
with onResponseClose, which keeps one listener per response and runs them in
the order added, so a streamed response holds three close listeners instead
of five or six and request tracing has room for its own.

* test(webdav): handle streamed response errors in test sink

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

* test(webdav): a storage failure mid-download is covered against a real response

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Salazareo <[email protected]>
2026-11-13 09:56:21 -07:00
Reynaldi Chernando 9d3a049988 document puter.host behavior (#4606)
* document puter.host behavior

* update
2026-11-13 19:13:59 +07:00
71 changed files with 2560 additions and 690 deletions

No files matched your search

+4
View File
@@ -62,6 +62,10 @@
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
// and makes X-Forwarded-For forgeable.
"trust_proxy": false,
// Request header a TLS-terminating proxy in front of the server sets to
// the client's TLS fingerprint (JA3/JA4). Only set it when that proxy
// overwrites any value the client sends. Unset disables it.
// "tlsFingerprintHeader": "x-tls-fingerprint",
"no_browser_launch": false,
// ── Dev watcher (devWatcher extension) ──────────────────────────────
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 99;
const CURRENT_SCHEMA_VERSION = 101;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -735,7 +735,12 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
'`recipient_email`) VALUES (?, ?, ?)',
['share-invite', user.id, '[email protected]'],
);
// The full schema as it stood before 0103, so only 0103 reruns.
// The full schema as it stood before 0103, so 0103 reruns; 0104
// is undone so it can apply again after it.
await before.write('DROP INDEX idx_user_signup_tls_fingerprint');
await before.write(
'ALTER TABLE `user` DROP COLUMN `signup_tls_fingerprint`',
);
await before.write('PRAGMA user_version = 98');
before.onServerShutdown();
@@ -138,6 +138,8 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[96, ['0101_user-scoped-handlers.sql']],
[97, ['0102_notification-scope-id-index.sql']],
[98, ['0103_share-entry-owner-backfill.sql']],
[99, ['0104_signup-tls-fingerprint.sql']],
[100, ['0105_sessions_kind_debug_app.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,51 @@
-- 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/>.
-- `user`.`signup_tls_fingerprint`. See
-- sqlite/0104_signup-tls-fingerprint.sql for the rationale. No per-file
-- applied-state tracking, so the column and index are added only when missing
-- and a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
DELIMITER //
CREATE PROCEDURE _puter_add_user_signup_tls_fingerprint()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'signup_tls_fingerprint'
) THEN
ALTER TABLE `user`
ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL,
ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_signup_tls_fingerprint'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_signup_tls_fingerprint`
(`signup_tls_fingerprint`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_user_signup_tls_fingerprint();
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
@@ -0,0 +1,46 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'debug_app' to the `sessions.kind` ENUM: a short-lived app session an
-- operator creates on a user's behalf. Mirrors SQLite migration 0105.
--
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is a
-- no-op once 'debug_app' is in the ENUM.
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
DELIMITER //
CREATE PROCEDURE _puter_sessions_kind_debug_app()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND COLUMN_NAME = 'kind'
AND FIND_IN_SET('debug_app', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
) THEN
ALTER TABLE `sessions`
MODIFY COLUMN `kind`
ENUM('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')
NOT NULL DEFAULT 'web',
ALGORITHM=INSTANT;
END IF;
END//
DELIMITER ;
CALL _puter_sessions_kind_debug_app();
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
@@ -0,0 +1,22 @@
-- 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/>.
-- "user".signup_tls_fingerprint. Mirrors SQLite migration 0104. Idempotent
-- via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS signup_tls_fingerprint varchar(64);
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON "user" (signup_tls_fingerprint);
@@ -0,0 +1,24 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'debug_app' to the `sessions.kind` CHECK constraint: a short-lived app
-- session an operator creates on a user's behalf. Mirrors SQLite migration
-- 0105. Drop-then-add keeps it idempotent across directory replays.
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_kind_check;
ALTER TABLE sessions ADD CONSTRAINT sessions_kind_check
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app'));
@@ -0,0 +1,22 @@
-- 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/>.
-- `user`.`signup_tls_fingerprint`: the TLS fingerprint of the request that
-- created the account, when a fronting proxy supplies one. Indexed so the
-- accounts created from one TLS stack can be listed.
ALTER TABLE `user` ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON `user` (`signup_tls_fingerprint`);
@@ -0,0 +1,89 @@
-- 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/>.
-- Add 'debug_app' to the `kind` CHECK constraint on `sessions`. Mirrors the
-- MySQL ENUM extension in mysql_mig_59.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the same
-- table-rebuild as migration 0056: create `sessions_new` with the corrected
-- constraint, copy rows, drop the old table, rename, then recreate every
-- index that lived on the original.
CREATE TABLE `sessions_new` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"uuid" TEXT NOT NULL,
"meta" JSON DEFAULT NULL,
"created_at" INTEGER DEFAULT 0,
"last_activity" INTEGER DEFAULT 0,
"kind" TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')),
"label" TEXT,
"parent_session_id" TEXT,
"last_ip" TEXT,
"last_user_agent" TEXT,
"revoked_at" INTEGER,
"expires_at" INTEGER,
"app_uid" TEXT,
"legacy_token_uid" TEXT,
"created_via" TEXT,
"auth_id" TEXT,
"access_token_uid" TEXT,
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `sessions_new` (
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
)
SELECT
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
FROM `sessions`;
DROP TABLE `sessions`;
ALTER TABLE `sessions_new` RENAME TO `sessions`;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
ON `sessions` (`user_id`, `app_uid`)
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
ON `sessions` (`legacy_token_uid`)
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
ON `sessions` (`kind`, `user_id`);
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
+2
View File
@@ -241,6 +241,8 @@ export type EventMap = {
trail_id?: string;
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: string | null;
/** TLS fingerprint of the signup request, when a proxy supplies one. */
tls_fingerprint?: string | null;
/**
* Set by the abuse harness — require SMS phone verification
* post-signup.
@@ -132,6 +132,28 @@ describe('PreludeClient', () => {
});
});
it('forwards a JA4 TLS fingerprint and drops any other shape', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
await client.createVerification('+14155550123', {
tls_fingerprint: ja4,
});
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
// A JA3 hash.
tls_fingerprint: 'e7d705a3286e19ea42f587b344ee6865',
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body).signals).toEqual({
ja4_fingerprint: ja4,
});
expect(JSON.parse(fetchMock.mock.calls[1][1].body).signals).toEqual({
ip: '203.0.113.7',
});
});
it('omits the signals object entirely when none are supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
@@ -25,6 +25,8 @@ const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
const REQUEST_TIMEOUT_MS = 8000;
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
const PRELUDE_CODE_SIZE = 6;
const JA4_SHAPE =
/^[tqd][0-9a-z]{2}[di]\d{4}[0-9a-z]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/;
/**
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
@@ -134,6 +136,8 @@ export class PreludeClient extends PuterClient {
ip?: string;
device_id?: string;
user_agent?: string;
/** Forwarded only when it is a JA4 value. */
tls_fingerprint?: string;
dispatch_id?: string;
} = {},
): Promise<{
@@ -168,6 +172,8 @@ export class PreludeClient extends PuterClient {
if (signals.ip) sig.ip = signals.ip;
if (signals.device_id) sig.device_id = signals.device_id;
if (signals.user_agent) sig.user_agent = signals.user_agent;
if (signals.tls_fingerprint && JA4_SHAPE.test(signals.tls_fingerprint))
sig.ja4_fingerprint = signals.tls_fingerprint;
if (Object.keys(sig).length > 0) body.signals = sig;
// dispatch_id is a top-level field, not a member of `signals`.
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
@@ -722,6 +722,36 @@ describe('AuthController.handleSignup', () => {
expect(audit.ip_fwd).toBe(forged);
});
it('records the request TLS fingerprint on the validate hook and the user row', async () => {
const username = `s_${uniq()}`;
const tlsFingerprint = 't13d1516h2_8daaf6152771_02713d6af862';
let seenTlsFingerprint: unknown;
await withSignupValidateOverride(
(event) => {
seenTlsFingerprint = (event as { tls_fingerprint?: unknown })
.tls_fingerprint;
},
async () => {
await controller.handleSignup(
{
...makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
tlsFingerprint,
},
makeRes(),
);
},
);
expect(seenTlsFingerprint).toBe(tlsFingerprint);
const persisted = await server.stores.user.getByUsername(username);
expect(persisted!.signup_tls_fingerprint).toBe(tlsFingerprint);
});
it('forces phone verification on every signup when always_require_phone_verification is set', async () => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.always_require_phone_verification;
@@ -1510,6 +1510,7 @@ export class AuthController extends PuterController {
ip,
device_id: req.deviceFingerprint ?? undefined,
user_agent: userAgent,
tls_fingerprint: req.tlsFingerprint,
dispatch_id: dispatchId,
},
);
@@ -4486,6 +4487,34 @@ export class AuthController extends PuterController {
res.json({ token: guiToken });
}
@Post('/auth/first-party-session', {
subdomain: 'api',
rateLimit: SESSION_LIMIT,
// Hands out a session, so only the GUI's own page script may ask, and
// only as the user's sign-in itself.
guiOriginOnly: true,
requireUserActor: true,
})
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
const { origin } = req.body ?? {};
if (typeof origin !== 'string' || !origin) {
throw new HttpError(400, 'Missing `origin`', {
legacyCode: 'bad_request',
});
}
const token = await this.services.auth.createFirstPartySessionToken(
req.actor!,
origin,
{
ip: req.ip || req.socket?.remoteAddress,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
host: req.headers?.host,
},
);
res.json({ token });
}
@Get('/session/sync-cookie', {
rateLimit: SESSION_LIMIT,
// Installs the session cookie. Only page script on our own origin
@@ -4510,6 +4539,14 @@ export class AuthController extends PuterController {
res.status(400).end();
return;
}
// A session handed to a first-party page never gets a cookie.
if (req.actor.session.parentUid) {
throw new HttpError(
403,
'This session cannot install a session cookie',
{ legacyCode: 'forbidden' },
);
}
// As in `/get-gui-token`: the actor is the user's row as auth read it.
const sessionToken = this.services.auth.createSessionTokenForSession(
req.actor.user as UserRow,
@@ -0,0 +1,282 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { IConfig } from '../../types.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PAGE = 'https://desk.first-party.test';
// Seeded with godmode on.
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
/**
* One of the deployment's own pages, served apart from the GUI, signs in with a
* `web` session of its own under the user's sign-in.
*/
describe('first-party page sessions', () => {
let env: PuterTestEnv;
let userId = 0;
beforeAll(async () => {
env = await setupPuterTestEnv({
first_party_session_origins: [`${PAGE}/`],
} as IConfig);
const row = await env.server.stores.user.getByUsername(
env.users.user.username,
);
userId = row!.id;
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
headers: Record<string, string> = {},
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** A sign-in of its own, so revoking it leaves the shared one up. */
const signIn = async () => {
const user = await env.server.stores.user.getById(userId);
const { gui_token, session } =
await env.server.services.auth.createSessionToken(user!);
return { token: gui_token, uuid: session.uuid as string };
};
const mint = async (token: string, origin = PAGE) => {
const res = await call('POST', '/auth/first-party-session', token, {
origin,
});
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
const actorOf = async (token: string) =>
(await env.server.services.auth.authenticate(token)).actor;
const sessions = async (token: string) => {
const res = await call('GET', '/auth/list-sessions', token);
expect(res.status).toBe(200);
return (await res.json()) as Array<Record<string, unknown>>;
};
it('hands the page a session of its own under the sign-in', async () => {
const root = await signIn();
const token = await mint(root.token);
const actor = await actorOf(token);
expect(actor?.app ?? null).toBeNull();
expect(actor?.accessToken ?? null).toBeNull();
expect(actor?.session).toMatchObject({
kind: 'web',
parentUid: root.uuid,
});
expect(actor?.session?.uid).not.toBe(root.uuid);
const row = (await sessions(root.token)).find(
(s) => s.uuid === actor!.session!.uid,
);
expect(row).toMatchObject({
kind: 'web',
parent_session_id: root.uuid,
first_party_origin: PAGE,
current: false,
});
});
it('reuses the session on a second ask', async () => {
const root = await signIn();
const first = await actorOf(await mint(root.token));
const second = await actorOf(await mint(root.token));
expect(second?.session?.uid).toBe(first?.session?.uid);
});
it('acts as the account: launches apps, godmode ones included', async () => {
const root = await signIn();
const token = await mint(root.token);
const res = await call('POST', '/auth/get-user-app-token', token, {
app_uid: DEV_CENTER,
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ godmode: true });
});
it('refuses an origin that is not listed', async () => {
const root = await signIn();
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{
origin: 'https://elsewhere.test',
},
);
expect(res.status).toBe(403);
});
it('refuses a page on another origin, the listed page included', async () => {
const root = await signIn();
for (const origin of [PAGE, 'https://elsewhere.test']) {
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{ origin: PAGE },
{ origin },
);
expect(res.status).toBe(403);
}
});
it('refuses app tokens, access tokens and a first-party session', async () => {
const root = await signIn();
const page = await mint(root.token);
const app = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
origin: 'https://some-app.test',
})
).json()) as { token: string };
const godmode = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
for (const token of [page, app.token, godmode.token]) {
const res = await call('POST', '/auth/first-party-session', token, {
origin: PAGE,
});
expect(res.status).toBe(403);
}
});
it('never gets a session cookie', async () => {
const root = await signIn();
const page = await mint(root.token);
const syncCookie = (token: string) =>
fetch(new URL('/session/sync-cookie', env.origin), {
headers: { authorization: `Bearer ${token}` },
});
expect((await syncCookie(root.token)).status).toBe(204);
const res = await syncCookie(page);
expect(res.status).toBe(403);
expect(res.headers.get('set-cookie')).toBeNull();
});
it('ends with the sign-in it was handed out under', async () => {
const root = await signIn();
const page = await mint(root.token);
expect(await actorOf(page)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
const result = await env.server.services.auth.authenticate(page);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');
});
it('takes the tokens minted under it along when its sign-in ends', async () => {
const root = await signIn();
const page = await mint(root.token);
const godmode = (await (
await call('POST', '/auth/get-user-app-token', page, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
const scoped = (await (
await call('POST', '/auth/create-access-token', godmode.token, {
permissions: ['service:foo:ii:read'],
expiresIn: '7d',
})
).json()) as { token: string };
expect(await actorOf(scoped.token)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
for (const token of [godmode.token, scoped.token]) {
expect(
(await env.server.services.auth.authenticate(token)).actor,
).toBeUndefined();
}
});
it('is revoked on its own without ending the sign-in', async () => {
const root = await signIn();
const page = await mint(root.token);
const pageUuid = (await actorOf(page))!.session!.uid;
await env.server.services.auth.revokeSession(pageUuid);
expect(
(await env.server.services.auth.authenticate(page)).actor,
).toBeUndefined();
expect(await actorOf(root.token)).toBeDefined();
});
it('keeps the whole current sign-in when revoking all others', async () => {
const root = await signIn();
const page = await mint(root.token);
const other = await signIn();
const otherPage = await mint(other.token);
// Asked from the page: its sign-in stays, and so does the page.
await env.server.services.auth.revokeAllSessions(
(await actorOf(page))!,
);
expect(await actorOf(page)).toBeDefined();
expect(await actorOf(root.token)).toBeDefined();
expect(
(await env.server.services.auth.authenticate(other.token)).actor,
).toBeUndefined();
expect(
(await env.server.services.auth.authenticate(otherPage)).actor,
).toBeUndefined();
// Asked from the sign-in: the page under it stays too.
await env.server.services.auth.revokeAllSessions(
(await actorOf(root.token))!,
);
expect(await actorOf(page)).toBeDefined();
});
it('is not counted as a sign-in', async () => {
const root = await signIn();
const before = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
await mint(root.token);
const after = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
expect(after.length).toBe(before.length);
});
});
@@ -1,165 +0,0 @@
/*
* 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 { HttpError } from '../../core/http/HttpError.js';
import { PuterController } from '../types.js';
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Folder view preference routes: layout and sort_by/sort_order, stored on the
* folder's fsentries row.
*/
/**
* Desktop preference writes — layout, sort order. Each persists on every call,
* and the GUI fires them on direct user action, so a per-minute ceiling well
* above human speed is enough to catch a stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
limit: 120,
window: 60_000,
key: 'user',
};
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Folder layout -------------------------------------------
router.post(
'/set_layout',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, layout } = req.body ?? {};
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
throw new HttpError(
400,
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{ layout },
);
res.json({});
},
);
// -- Folder sort ---------------------------------------------
router.post(
'/set_sort_by',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, sort_by, sort_order } =
req.body ?? {};
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
throw new HttpError(
400,
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
const resolvedOrder = sort_order ?? 'asc';
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
throw new HttpError(
400,
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{
sort_by,
sort_order: resolvedOrder,
},
);
res.json({});
},
);
}
// -- Helpers ------------------------------------------------------
/**
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
* path) or `item_path` (path lookups in FSEntryStore now have a
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
* works for old accounts too).
*
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
* against bad paths — the previous "drop user_id entirely" theory turned
* out to be wrong (the actual legacy issue was NULL paths, not user_id
* drift), so this filter doesn't lock out old accounts in practice.
*/
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
if (!item_uid && !item_path) {
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
legacyCode: 'bad_request',
});
}
const entry = item_uid
? await this.stores.fsEntry.getEntryByUuid(item_uid)
: await this.stores.fsEntry.getEntryByPath(item_path);
if (!entry) {
throw new HttpError(404, 'Item not found', {
legacyCode: 'not_found',
});
}
const actorUserId = actor?.user?.id;
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
throw new HttpError(403, 'Not allowed to update this item', {
legacyCode: 'forbidden',
});
}
const keys = Object.keys(patch);
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
const values = keys.map((k) => patch[k]);
await this.clients.db.write(
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
[...values, entry.id],
);
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
}
onServerStart() {}
onServerPrepareShutdown() {}
onServerShutdown() {}
}
@@ -1,381 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
// redis) and re-registers DesktopController's inline lambda routes
// onto a fresh PuterRouter so each handler is reachable. Each test
// makes its own user via `makeUser` and exercises the live controller
// against the real wired stores (user, fsEntry) and DB client.
let server: PuterServer;
let router: PuterRouter;
beforeAll(async () => {
server = await setupTestServer();
router = new PuterRouter();
server.controllers.desktop.registerRoutes(router);
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
interface CapturedResponse {
statusCode: number;
body: unknown;
}
const makeReq = (init: {
body?: unknown;
actor?: Actor;
}): Request => {
return {
body: init.body ?? {},
query: {},
headers: {},
actor: init.actor,
} as unknown as Request;
};
const makeRes = () => {
const captured: CapturedResponse = { statusCode: 200, body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn((code: number) => {
captured.statusCode = code;
return res;
}),
setHeader: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
const findHandler = (method: string, path: string): RequestHandler => {
const route = router.routes.find(
(r) => r.method === method && r.path === path,
);
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
return route.handler;
};
const callRoute = async (
method: string,
path: string,
req: Request,
res: Response,
) => {
const handler = findHandler(method, path);
await handler(req, res, () => {
throw new Error('handler called next() unexpectedly');
});
};
describe('DesktopController routes', () => {
it('has no desktop background route', () => {
expect(
router.routes.some((r) => r.path === '/set-desktop-bg'),
).toBe(false);
});
it('has no taskbar items route', () => {
expect(
router.routes.some((r) => r.path === '/update-taskbar-items'),
).toBe(false);
});
});
// ── /set_layout ─────────────────────────────────────────────────────
describe('DesktopController POST /set_layout', () => {
it('persists the new layout on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
expect(documents).not.toBeNull();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'icons' },
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.layout).toBe('icons');
});
it('resolves by item_path when item_uid is omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Pictures`;
const { res } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_path: path, layout: 'list' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
expect(refreshed?.layout).toBe('list');
});
it('throws 400 for an unknown layout value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'gallery' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({ body: { layout: 'icons' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 404 when the fsentry cannot be resolved', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: {
item_uid: '00000000-0000-0000-0000-000000000000',
layout: 'icons',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 404 });
});
it("throws 403 when the fsentry belongs to another user", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const ownerUsername = owner.actor.user!.username!;
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
`/${ownerUsername}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
actor: intruder.actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
// Owner's entry remains untouched.
const refreshed = await server.stores.fsEntry.getEntryByUuid(
ownerEntry!.uuid,
);
expect(refreshed?.layout).toBeFalsy();
});
});
// ── /set_sort_by ────────────────────────────────────────────────────
describe('DesktopController POST /set_sort_by', () => {
it('persists sort_by + sort_order on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: documents!.uuid,
sort_by: 'name',
sort_order: 'desc',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.sortBy).toBe('name');
expect(refreshed?.sortOrder).toBe('desc');
});
it('defaults sort_order to "asc" when omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const pictures = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures`,
);
const { res } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByUuid(
pictures!.uuid,
);
expect(refreshed?.sortBy).toBe('modified');
expect(refreshed?.sortOrder).toBe('asc');
});
it('throws 400 for an unknown sort_by value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: docs!.uuid, sort_by: 'random' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 for an invalid sort_order', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: docs!.uuid,
sort_by: 'name',
sort_order: 'sideways',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
+2 -1
View File
@@ -18,6 +18,7 @@
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
@@ -46,7 +47,7 @@ export class CallSlot {
this.#maybeRelease();
};
res.once('finish', responded);
res.once('close', responded);
onResponseClose(res, responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
-2
View File
@@ -21,7 +21,6 @@ import { AppController } from './apps/AppController.js';
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
import { AuthController } from './auth/AuthController.js';
import { BroadcastController } from './broadcast/BroadcastController.js';
import { DesktopController } from './desktop/DesktopController.js';
import { DriverController } from './drivers/DriverController.js';
import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
@@ -49,7 +48,6 @@ export const puterControllers = {
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
hosting: HostingController,
system: SystemController,
fs: FSController,
+2 -1
View File
@@ -26,6 +26,7 @@
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
import type { UsageDetails } from '../../drivers/ai/interfaces/chat/types.js';
import { promoteStopForToolCalls } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
import { anthropicUsage } from './anthropicWire.js';
@@ -79,7 +80,7 @@ export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
timer.unref?.();
}
res.once('close', () => {
onResponseClose(res, () => {
ended = true;
if (timer) clearInterval(timer);
});
@@ -0,0 +1,210 @@
/*
* 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 { GetObjectCommand } from '@aws-sdk/client-s3';
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
TEST_USER_CREDENTIALS,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/** File downloads over WebDAV: the bytes, ranges, and either end failing. */
describe('WebDAV GET over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 60_000);
afterAll(async () => {
await env?.shutdown();
});
const dav = (path: string, init: RequestInit = {}) => {
const port = new URL(env.apiOrigin).port;
const auth = Buffer.from(
`${TEST_USER_CREDENTIALS.username}:${TEST_USER_CREDENTIALS.password}`,
).toString('base64');
return fetch(`http://dav.puter.localhost:${port}${encodeURI(path)}`, {
...init,
headers: {
Authorization: `Basic ${auth}`,
// Uncompressed, so the bytes on the wire are the stored ones.
'Accept-Encoding': 'identity',
...(init.headers as Record<string, string> | undefined),
},
});
};
const writeFile = async (name: string, content: Buffer) => {
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const path = `/${username}/Documents/${name}`;
const { fsEntry } = await env.server.services.fs.write(user!.id, {
fileMetadata: {
path,
size: content.byteLength,
contentType: 'application/octet-stream',
},
fileContent: content,
});
return { path, uuid: fsEntry.uuid };
};
it('returns the stored bytes, whole or as a range', async () => {
const content = randomBytes(128 * 1024 + 3);
const file = await writeFile('dav-get.bin', content);
const whole = await dav(file.path);
expect(whole.status).toBe(200);
expect(whole.headers.get('content-length')).toBe(
String(content.byteLength),
);
expect(Buffer.from(await whole.arrayBuffer())).toEqual(content);
const range = await dav(file.path, {
headers: { Range: 'bytes=10-19' },
});
expect(range.status).toBe(206);
expect(range.headers.get('content-range')).toBe(
`bytes 10-19/${content.byteLength}`,
);
expect(Buffer.from(await range.arrayBuffer())).toEqual(
content.subarray(10, 20),
);
});
/**
* Storage hands over one chunk of `file` and then goes quiet, as a large
* object does while the client is still reading.
*/
const stallStorage = (file: { uuid: string }) => {
const s3 = env.server.stores.s3Object;
const client = env.server.clients.s3.get(s3.resolveRegion(null));
const send = client.send.bind(client);
const bodies: Readable[] = [];
const spy = vi.spyOn(client, 'send').mockImplementation(((
command: unknown,
) => {
if (
!(command instanceof GetObjectCommand) ||
command.input.Key !== file.uuid
) {
return send(command as never);
}
let pushed = false;
const body = new Readable({
read() {
if (!pushed) {
pushed = true;
this.push(Buffer.alloc(16 * 1024, 1));
}
},
});
bodies.push(body);
return Promise.resolve({
Body: body,
ContentLength: 1024 * 1024,
ContentType: 'application/octet-stream',
});
}) as never);
return { bodies, restore: () => spy.mockRestore() };
};
const expectFullDownload = async (path: string) => {
const response = await dav(path);
expect(response.status).toBe(200);
expect((await response.arrayBuffer()).byteLength).toBe(1024 * 1024);
};
it('releases the storage stream when the client aborts', async () => {
const file = await writeFile(
'dav-abort.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const abort = new AbortController();
const response = await dav(file.path, { signal: abort.signal });
expect(response.status).toBe(200);
await response.body!.getReader().read();
abort.abort();
expect(storage.bodies).toHaveLength(1);
await vi.waitFor(() =>
expect(storage.bodies[0]!.destroyed).toBe(true),
);
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('cuts the download off when storage fails mid-stream', async () => {
const file = await writeFile(
'dav-reset.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
await reader.read();
storage.bodies[0]!.destroy(new Error('upstream reset'));
const outcome = await (async () => {
try {
while (!(await reader.read()).done);
return 'ended';
} catch {
return 'cut off';
}
})();
expect(outcome).toBe('cut off');
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('stays within the close listener budget per response', async () => {
const file = await writeFile('dav-listeners.bin', randomBytes(4096));
const tracker = trackResponseCloseListeners();
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak(
(req) => req.method === 'GET' && req.url === file.path,
);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
@@ -1365,6 +1365,7 @@ describe('WebDAVController verbs', () => {
callback();
},
});
sink.on('error', () => {});
sink.on('finish', () => {
captured.ended = true;
});
@@ -21,7 +21,6 @@ import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
@@ -551,12 +550,7 @@ export class WebDAVController extends PuterController {
'Content-Length': String(result.contentLength ?? 0),
});
}
try {
await pipeline(result.body, res);
} catch {
// Client disconnect or upstream stream error: pipeline already
// tore down both ends, so nothing is left dangling to crash on.
}
await pipeToResponse(result.body, res);
}
// -- PROPFIND ----------------------------------------------------
+7 -1
View File
@@ -76,8 +76,14 @@ export interface Actor {
/**
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
* `parentUid` is the sign-in a `web` session was handed out under, set only
* on a first-party page's session.
*/
session?: { uid: string; kind?: string | null } | null;
session?: {
uid: string;
kind?: string | null;
parentUid?: string | null;
} | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
+2 -1
View File
@@ -19,6 +19,7 @@
import type { Response } from 'express';
import { HttpError } from './HttpError.js';
import { onResponseClose } from './onResponseClose.js';
/**
* A signal that fires when the caller hangs up before its response is done, so
@@ -38,6 +39,6 @@ export const abortOnDisconnect = (res: Response): AbortSignal => {
}
};
if (res.destroyed) onClose();
else res.once('close', onClose);
else onResponseClose(res, onClose);
return abort.signal;
};
@@ -104,6 +104,13 @@ declare global {
*/
deviceFingerprint?: string;
/**
* The client's TLS fingerprint, read from the header named by
* `tlsFingerprintHeader`. Shared by every client on the same TLS
* stack, so it identifies software, not a person.
*/
tlsFingerprint?: string;
/**
* Parsed cookies, populated by the global `cookie-parser`
* middleware.
@@ -28,6 +28,7 @@ import {
} from '../../../services/metering/costs.js';
import type { UsageInput } from '../../../services/metering/types';
import '../expressAugmentation';
import { onResponseClose } from '../onResponseClose';
/**
* Subset of the metering service this middleware needs. Metering is optional
@@ -195,7 +196,7 @@ export const createEgressMeteringMiddleware = (
// 'close' rather than 'finish': it fires for a response that completed
// AND for one whose connection died mid-stream, and an aborted download
// still sent whatever it sent.
res.once('close', () => {
onResponseClose(res, () => {
try {
if (!isMeteredHost(req)) return;
const actor = resolveEgressActor(req);
@@ -23,8 +23,11 @@ import { createFingerprintMiddleware } from './fingerprint';
const VALID_FP = 'dev-fingerprint-abc123';
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
const middleware = createFingerprintMiddleware();
const run = (
reqPartial: Partial<Request> & Record<string, unknown>,
opts?: Parameters<typeof createFingerprintMiddleware>[0],
) => {
const middleware = createFingerprintMiddleware(opts);
const req = {
headers: {},
...reqPartial,
@@ -90,4 +93,21 @@ describe('createFingerprintMiddleware', () => {
const { req } = run({ ip: '203.0.113.1' });
expect(req.deviceFingerprint).toBeUndefined();
});
it('reads the TLS fingerprint from the configured header only', () => {
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
const headers = { 'x-tls-fp': ja4 };
expect(run({ headers }).req.tlsFingerprint).toBeUndefined();
expect(
run({ headers }, { tlsFingerprintHeader: 'X-TLS-FP' }).req
.tlsFingerprint,
).toBe(ja4);
expect(
run(
{ headers: { 'x-tls-fp': 'not a fingerprint!' } },
{ tlsFingerprintHeader: 'x-tls-fp' },
).req.tlsFingerprint,
).toBeUndefined();
});
});
@@ -33,6 +33,20 @@ const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
/** Header the GUI may send the device fingerprint on for non-signup requests. */
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
/** JA3 / JA4-style values; anything else is dropped. */
const TLS_FINGERPRINT_SHAPE = /^[A-Za-z0-9_]{10,64}$/;
function readTlsFingerprint(
req: { headers?: Record<string, unknown> },
header: string | undefined,
): string | undefined {
if (!header) return undefined;
const value = req.headers?.[header];
return typeof value === 'string' && TLS_FINGERPRINT_SHAPE.test(value)
? value
: undefined;
}
function readDeviceFingerprint(req: {
body?: unknown;
headers?: Record<string, unknown>;
@@ -65,15 +79,22 @@ function readDeviceFingerprint(req: {
* and spoofable, but stable per real device across IP rotation. The rate
* limiter's 'fingerprint' strategy appends it to the network hash so each
* device behind a shared network gets its own bucket.
* - `req.tlsFingerprint` — set only when `tlsFingerprintHeader` is configured and
* the request carries a well-shaped value in that header. It names the
* client's TLS stack, so every client on the same build shares it.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
* Never rejects — a missing/invalid device fingerprint is simply absent.
*/
export const createFingerprintMiddleware = (): RequestHandler => {
export const createFingerprintMiddleware = (
opts: { tlsFingerprintHeader?: string } = {},
): RequestHandler => {
const tlsHeader = opts.tlsFingerprintHeader?.toLowerCase();
return (req, _res, next) => {
req.networkFingerprint = computeNetworkFingerprint(req);
req.deviceFingerprint = readDeviceFingerprint(req);
req.tlsFingerprint = readTlsFingerprint(req, tlsHeader);
next();
};
};
@@ -20,6 +20,7 @@
import type { Request, RequestHandler, Response } from 'express';
import { actorUid } from '../../actor';
import { HttpError } from '../HttpError';
import { onResponseClose } from '../onResponseClose';
import { createJsonBodyParser } from './jsonBody';
/** The global JSON parser's limit, which applies before auth. */
@@ -152,7 +153,7 @@ export const createLargeJsonBodyParser = ({
}
inFlightBytes += declared;
inFlightByUser.set(user, userBytes + declared);
res.once('close', () => {
onResponseClose(res, () => {
inFlightBytes -= declared;
const left = (inFlightByUser.get(user) ?? 0) - declared;
if (left > 0) inFlightByUser.set(user, left);
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
* Browsers send none of those variations, but config values do — a hand-edited
* `origin` with a trailing slash would otherwise reject every real request.
*/
const normalizeOrigin = (raw: string | undefined): string =>
export const normalizeOrigin = (raw: string | undefined): string =>
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
/**
@@ -21,6 +21,7 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import { onResponseClose } from '../onResponseClose.js';
import { subscriptionOverride } from '../../../services/metering/consts.js';
/**
@@ -1169,7 +1170,7 @@ export function concurrencyGate(opts) {
const { release } = guardSlot(slot, '[concurrent]');
const onDone = () => void release();
res.once('finish', onDone);
res.once('close', onDone);
onResponseClose(res, onDone);
if (res.destroyed || res.writableFinished) onDone();
next();
};
@@ -0,0 +1,105 @@
/*
* 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 { EventEmitter } from 'node:events';
import { createServer, get, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { onResponseClose } from './onResponseClose.js';
let server: Server | undefined;
afterEach(async () => {
server?.closeAllConnections();
await new Promise((resolve) => server?.close(resolve) ?? resolve(null));
server = undefined;
});
describe('onResponseClose', () => {
it('shares one close listener and runs every callback once, in order', () => {
const res = new EventEmitter();
const calls: string[] = [];
onResponseClose(res, () => calls.push('a'));
onResponseClose(res, () => calls.push('b'));
onResponseClose(res, () => calls.push('c'));
expect(res.listenerCount('close')).toBe(1);
res.emit('close');
res.emit('close');
expect(calls).toEqual(['a', 'b', 'c']);
expect(res.listenerCount('close')).toBe(0);
});
it('keeps responses apart', () => {
const first = new EventEmitter();
const second = new EventEmitter();
const onFirst = vi.fn();
const onSecond = vi.fn();
onResponseClose(first, onFirst);
onResponseClose(second, onSecond);
first.emit('close');
expect(onFirst).toHaveBeenCalledOnce();
expect(onSecond).not.toHaveBeenCalled();
});
it('runs the rest when one callback throws, and still surfaces the error', async () => {
const res = new EventEmitter();
const after = vi.fn();
const failure = new Error('callback broke');
const uncaught = vi.fn();
const listeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
process.on('uncaughtException', uncaught);
try {
onResponseClose(res, () => {
throw failure;
});
onResponseClose(res, after);
res.emit('close');
expect(after).toHaveBeenCalledOnce();
await vi.waitFor(() =>
expect(uncaught).toHaveBeenCalledWith(
failure,
expect.anything(),
),
);
} finally {
process.off('uncaughtException', uncaught);
for (const listener of listeners)
process.on('uncaughtException', listener);
}
});
it('fires when a client hangs up on a real response', async () => {
const closed = new Promise<boolean>((resolve) => {
server = createServer((_req, res) => {
onResponseClose(res, () => resolve(res.writableFinished));
res.write('partial');
});
});
await new Promise<void>((resolve) => server!.listen(0, resolve));
const { port } = server!.address() as AddressInfo;
const req = get(`http://127.0.0.1:${port}/`, (res) => {
res.once('data', () => req.destroy());
});
req.on('error', () => {});
expect(await closed).toBe(false);
});
});
+57
View File
@@ -0,0 +1,57 @@
/*
* 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/>.
*/
/** Only what's used here, so test doubles and express's Response both fit. */
interface ClosingResponse {
once(event: 'close', listener: () => void): unknown;
}
const callbacksByResponse = new WeakMap<ClosingResponse, Array<() => void>>();
/**
* Runs `callback` once when the response closes. Every callback on a response
* shares one `close` listener, since Node warns past ten and request tracing
* adds its own. Callbacks run in the order added; one that throws doesn't stop
* the rest.
*/
export const onResponseClose = (
res: ClosingResponse,
callback: () => void,
): void => {
const callbacks = callbacksByResponse.get(res);
if (callbacks) {
callbacks.push(callback);
return;
}
const pending = [callback];
callbacksByResponse.set(res, pending);
res.once('close', () => {
for (const run of pending.splice(0)) {
try {
run();
} catch (e) {
// Surfaces as an uncaught error, as it would from a listener
// of its own.
queueMicrotask(() => {
throw e;
});
}
}
});
};
@@ -0,0 +1,147 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/**
* The routes that hold a response open the longest, where middleware, gates and
* the handler each want to hear when it closes.
*/
describe('close listeners per response over HTTP', () => {
let env: PuterTestEnv;
let filePath = '';
beforeAll(async () => {
env = await setupPuterTestEnv({
meteringEnforcement: { subscriptions: false },
} as never);
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
filePath = `/${username}/Documents/close-listeners.txt`;
await env.server.services.fs.write(user!.id, {
fileMetadata: {
path: filePath,
size: 4096,
contentType: 'text/plain',
},
fileContent: Buffer.alloc(4096, 97),
});
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const streamedChat = {
model: 'fake',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
};
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Origin: env.apiOrigin,
},
body: JSON.stringify(body),
});
const routes: Array<[string, string, () => Promise<Response>]> = [
[
'a streamed driver call',
'/drivers/call',
() =>
post('/drivers/call', env.users.user.token, {
interface: 'puter-chat-completion',
service: 'ai-chat',
method: 'complete',
args: streamedChat,
}),
],
[
'an Anthropic-style streamed completion',
'/puterai/anthropic/v1/messages',
() =>
post(
'/puterai/anthropic/v1/messages',
env.users.user.apiToken,
streamedChat,
),
],
[
'an OpenAI-style streamed completion',
'/puterai/openai/v1/chat/completions',
() =>
post(
'/puterai/openai/v1/chat/completions',
env.users.user.apiToken,
streamedChat,
),
],
[
'a file read',
'/read',
() => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
[
'a v2 file read',
'/fs/read',
() => {
const url = new URL('/fs/read', env.apiOrigin);
url.searchParams.set('path', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
];
it.each(routes)('%s stays within the budget', async (_name, path, call) => {
const tracker = trackResponseCloseListeners();
try {
const response = await call();
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak((req) => req.url?.split('?')[0] === path);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
+2 -1
View File
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
import { actorUid } from '../actor';
import type { EventClient } from '../../clients/event/EventClient';
import type { LifecycleRejectStatus } from '../../clients/event/types';
import { onResponseClose } from './onResponseClose';
import type { RouteMethod } from './types';
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
@@ -179,7 +180,7 @@ export const createRouteLifecycleMiddleware = (
);
};
res.once('finish', () => settle(false));
res.once('close', () => settle(!res.writableFinished));
onResponseClose(res, () => settle(!res.writableFinished));
next();
};
};
@@ -129,9 +129,9 @@ describe('pipeStreamResult', () => {
expect(warn).not.toHaveBeenCalled();
});
it('adds at most two close listeners to the response', async () => {
// The response already carries close listeners from metering,
// compression and tracing; Node warns past ten.
it('adds no close listener beyond the one pipe adds', async () => {
// The response already carries close listeners from middleware and
// request tracing; Node warns past ten.
let added = -1;
server = createServer((_req, res) => {
const before = res.listenerCount('close');
@@ -146,8 +146,7 @@ describe('pipeStreamResult', () => {
res.on('end', () => resolve());
}),
);
expect(added).toBeGreaterThan(0);
expect(added).toBeLessThanOrEqual(2);
expect(added).toBe(1);
});
it('drops the source, without throwing, when the client left before the result was ready', async () => {
+7 -8
View File
@@ -33,9 +33,9 @@ type ResponseSink = NodeJS.WritableStream & {
* than a clean end, and the failure is logged once instead of surfacing as an
* unhandled stream error. A client that hangs up stops the source.
*
* `pipe` rather than `pipeline`: pipeline puts seven close listeners on the
* response, and with the ones metering, compression and tracing add, every
* streamed response tripped Node's ten-listener warning.
* `pipe` rather than `pipeline`, and `unpipe` rather than `close`: Node warns
* past ten close listeners on the response, and other middleware and request
* tracing add their own.
*/
export function pipeStreamResult(
stream: Readable,
@@ -52,11 +52,10 @@ export function pipeStreamResult(
if (!res.destroyed) res.destroy();
console.warn(`[drivers] ${label} stream failed mid-response:`, err);
});
// A response that closes before the source ended is a client hanging up.
res.once('close', () => {
if (stream.readableEnded) return;
stream.unpipe(res);
stream.destroy();
// `pipe` unpipes once the response finishes, closes or fails; a source
// that hasn't ended by then has a client that hung up.
res.once('unpipe', () => {
if (!stream.readableEnded) stream.destroy();
});
res.once('error', () => stream.destroy());
stream.pipe(res);
+53
View File
@@ -645,6 +645,59 @@ describe('PuterServer host header validation — permissive modes', () => {
});
});
describe('PuterServer behind a trusted proxy', () => {
let server: PuterServer;
let port: number;
beforeAll(async () => {
port = await allocateEphemeralPort();
server = await setupTestServer(
{
port,
domain: 'puter.localhost',
origin: `http://puter.localhost:${port}`,
api_base_url: `http://api.puter.localhost:${port}`,
allow_all_host_values: false,
allow_no_host_header: false,
custom_domains_enabled: false,
static_hosting_domain: 'site.puter.localhost',
trust_proxy: 1,
} as unknown as IConfig,
{ listen: true },
);
});
afterAll(async () => {
await server?.shutdown();
});
it('serves the main domain whatever X-Forwarded-Host names', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `puter.localhost:${port}`,
'x-forwarded-host': 'nosuchsite.site.puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('keeps a hosted-site host on site hosting', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `nosuchsite.site.puter.localhost:${port}`,
'x-forwarded-host': 'api.puter.localhost',
});
expect(res.status).toBe(404);
});
it('reads subdomains from Host', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `api.puter.localhost:${port}`,
'x-forwarded-host': 'puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
});
});
describe('PuterServer host header validation — custom domains with every host accepted', () => {
let server: PuterServer;
let port: number;
+16 -1
View File
@@ -416,6 +416,17 @@ export class PuterServer {
* `#materializeRoute` as those options ship.
*/
#installGlobalMiddleware() {
// -- Forwarded host ------------------------------------------
// With `trust proxy` set, express derives `req.hostname` and
// `req.subdomains` from X-Forwarded-Host, which any client can send.
// Dropping it keeps them on Host, the header host validation checks,
// so a request can't pick a different site or subdomain router than
// the host it was sent to.
this.#app.use((req, _res, next) => {
delete req.headers['x-forwarded-host'];
next();
});
// -- Egress metering -----------------------------------------
// First, so the byte counter wraps `res.write` before compression
// does and therefore counts what actually goes out rather than what
@@ -625,7 +636,11 @@ export class PuterServer {
// `req.deviceFingerprint` (client-supplied, from body/header). Runs
// AFTER body parsing so the body fingerprint is readable, and before
// the ALS context so the snapshot carries them.
this.#app.use(createFingerprintMiddleware());
this.#app.use(
createFingerprintMiddleware({
tlsFingerprintHeader: this.#config.tlsFingerprintHeader,
}),
);
// -- Per-request ALS context ---------------------------------
// Runs AFTER auth probe so `req.actor` is already populated when
@@ -124,6 +124,106 @@ describe('AuthService (integration)', () => {
return u;
};
const makeDebugApp = async (): Promise<string> => {
const uid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[uid, `n-${uid}`, `t-${uid}`, `https://${uid}.example/`, 1],
);
return uid;
};
describe('createDebugAppToken', () => {
it('mints an app-under-user token on its own expiring, hidden session', async () => {
const user = await makeUser();
const appUid = await makeDebugApp();
const { token, sessionUuid, expiresAt } =
await authService.createDebugAppToken(
user as never,
{ uid: appUid },
{
ttlSeconds: 3600,
label: 'debug',
meta: { debug_admin_username: 'op' },
},
);
// Its own dedicated row, not reusing a kind='app' session.
const row = (
(await server.clients.db.read(
'SELECT `kind`, `app_uid`, `expires_at` FROM `sessions` WHERE `uuid` = ?',
[sessionUuid],
)) as Array<Record<string, unknown>>
)[0];
expect(row?.kind).toBe('debug_app');
expect(row?.app_uid).toBe(appUid);
expect(Number(row?.expires_at)).toBeGreaterThan(
Math.floor(Date.now() / 1000) + 3500,
);
expect(expiresAt).toBeGreaterThan(Date.now() + 3500 * 1000);
// Authenticates as the user acting in the app.
const { actor } = await authService.authenticate(token);
expect(actor?.user.uuid).toBe(user.uuid);
expect(actor?.app?.uid).toBe(appUid);
// Hidden from the user's own session list.
const listed = await authService.listSessions({
user: { id: user.id },
} as Actor);
expect(listed.some((s) => s.uuid === sessionUuid)).toBe(false);
});
it('carries an exp, so the token lapses on its own', async () => {
const user = await makeUser();
const appUid = await makeDebugApp();
const { token } = await authService.createDebugAppToken(
user as never,
{ uid: appUid },
{ ttlSeconds: 1234 },
);
const claims = server.services.token.verify('auth', token) as {
iat: number;
exp: number;
};
expect(claims.exp - claims.iat).toBe(1234);
});
it('records no activity for the user', async () => {
const user = await makeUser();
const appUid = await makeDebugApp();
const { token } = await authService.createDebugAppToken(
user as never,
{ uid: appUid },
{ ttlSeconds: 600 },
);
const touch = vi.spyOn(server.stores.session, 'touch');
try {
const ctx = { ip: '9.9.9.9', userAgent: 'debug' };
expect(
(await authService.authenticate(token, ctx)).actor,
).toBeTruthy();
expect(touch).not.toHaveBeenCalled();
} finally {
touch.mockRestore();
}
});
it('is evicted by a password reset', async () => {
const user = await makeUser();
const appUid = await makeDebugApp();
const { token } = await authService.createDebugAppToken(
user as never,
{ uid: appUid },
{ ttlSeconds: 600 },
);
await authService.revokeInteractiveSessionsForUserId(user.id);
const result = await authService.authenticate(token);
expect(result.actor).toBeUndefined();
expect(result.reauth).toEqual({ reason: 'session_revoked' });
});
});
describe('authenticateFromToken', () => {
it('returns null for a malformed/unverifiable token', async () => {
const actor = await authService.authenticateFromToken('not-a-jwt');
+152 -8
View File
@@ -26,11 +26,13 @@ import {
type Actor,
} from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
import { normalizeOrigin } from '../../core/http/middleware/originGate.js';
import { WEB_AND_EXTENSION_PROTOCOLS } from '../../util/validation.js';
import { awaitLater } from '../../util/concurrency.js';
import { appGenerationStart } from '../../util/appGeneration.js';
import {
ASSET_WINDOW_SECONDS,
DEBUG_APP_SESSION_KIND,
GODMODE_TOKEN_MAX_AGE_SECONDS,
GODMODE_TOKEN_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
@@ -392,6 +394,68 @@ export class AuthService extends PuterService {
);
}
/**
* Whether `origin` is one of the deployment's own pages that sign in with a
* session of their own (`first_party_session_origins`).
*/
isFirstPartySessionOrigin(origin: unknown): boolean {
if (typeof origin !== 'string' || !origin) return false;
const wanted = normalizeOrigin(origin);
return (this.config.first_party_session_origins ?? []).some(
(listed) => normalizeOrigin(listed) === wanted,
);
}
/**
* Mint the GUI token one of the deployment's own pages signs in with: a
* `web` session of its own under the caller's sign-in, so it is listed and
* revoked apart from that sign-in and ends with it. Only a sign-in itself
* may ask, never a session handed out this way. No session cookie is ever
* issued for the row.
*/
async createFirstPartySessionToken(
actor: Actor,
origin: string,
meta: Record<string, unknown> = {},
): Promise<string> {
if (
!isPlainUserActor(actor) ||
!actor.user?.id ||
!actor.user.uuid ||
actor.session?.kind !== 'web' ||
actor.session.parentUid
) {
throw new HttpError(
403,
'Only a signed-in browser session can sign this page in',
{ legacyCode: 'forbidden' },
);
}
if (!this.isFirstPartySessionOrigin(origin)) {
throw new HttpError(403, 'This origin cannot be signed in', {
legacyCode: 'forbidden',
});
}
const auth_id = this.#authIdFor(actor.user as UserRow);
const row = await this.stores.session.getOrCreateFirstPartySession(
actor.user.id,
{
parentSessionUid: actor.session.uid,
origin: normalizeOrigin(origin),
expiresAt: nowSeconds() + WEB_WINDOW_SECONDS,
meta,
auth_id,
},
);
if (!row?.uuid) {
throw new HttpError(500, 'Could not sign this page in', {
legacyCode: 'internal_error',
});
}
return this.createGuiToken(actor.user as UserRow, row.uuid);
}
/** Sign a session token for an existing session (upgrade from GUI token). */
createSessionTokenForSession(user: UserRow, sessionUuid: string): string {
return this.#signSessionTypeToken(
@@ -787,7 +851,9 @@ export class AuthService extends PuterService {
const visible = [...live.values()].filter(
(row) =>
row.kind !== 'asset' &&
(row.kind !== 'access_token' ||
// Operator-created debug sessions are never the user's own.
row.kind !== DEBUG_APP_SESSION_KIND &&
((row.kind !== 'access_token' && row.kind !== 'web') ||
!row.parent_session_id ||
live.has(row.parent_session_id)),
);
@@ -1075,7 +1141,12 @@ export class AuthService extends PuterService {
});
await this.#revokeCascades(
rows
.filter((row) => row.kind === 'web' || row.kind === 'app')
.filter(
(row) =>
row.kind === 'web' ||
row.kind === 'app' ||
row.kind === DEBUG_APP_SESSION_KIND,
)
.map((row) => row.uuid as string),
);
}
@@ -1089,16 +1160,24 @@ export class AuthService extends PuterService {
legacyCode: 'forbidden',
});
}
const currentUuid = actor.session?.uid;
const rows = await this.stores.session.getByUserId(
// The current sign-in is the session, the sign-in it was handed out
// under, and the sessions handed out under either.
const current = new Set(
[actor.session?.uid, actor.session?.parentUid].filter(Boolean),
);
const rows = (await this.stores.session.getByUserId(
actor.user.id as number,
{ primary: true },
);
)) as SessionRow[];
await this.#revokeCascades(
rows
.filter((row) =>
row.kind === 'web'
? opts.includeCurrent || row.uuid !== currentUuid
? opts.includeCurrent ||
!(
current.has(row.uuid) ||
current.has(row.parent_session_id ?? '')
)
: row.kind === 'app' && opts.includeApps,
)
.map((row) => row.uuid as string),
@@ -1858,6 +1937,49 @@ export class AuthService extends PuterService {
});
}
/**
* An app token acting as `user` in `app` on its own `debug_app` session,
* which expires after `ttlSeconds` and never touches the user's real
* sessions. The caller authorizes who may ask; `meta` lands on the row.
*/
async createDebugAppToken(
user: UserRow,
app: { uid: string },
opts: {
ttlSeconds: number;
label?: string | null;
meta?: Record<string, unknown>;
},
): Promise<{ token: string; sessionUuid: string; expiresAt: number }> {
const authId = this.#authIdFor(user);
const expiresAtSeconds = nowSeconds() + opts.ttlSeconds;
const session = await this.stores.session.create(user.id as number, {
kind: DEBUG_APP_SESSION_KIND,
app_uid: app.uid,
expires_at: expiresAtSeconds,
auth_id: authId,
label: opts.label ?? null,
meta: opts.meta ?? {},
});
const token = this.services.token.sign(
'auth',
{
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: app.uid,
session_uid: session.uuid,
auth_id: authId,
},
{ expiresIn: opts.ttlSeconds },
);
return {
token,
sessionUuid: session.uuid as string,
expiresAt: expiresAtSeconds * 1000,
};
}
/**
* Mint the full-access token a godmode app runs on. Only the desktop's own
* web session may ask, and only asking extends it: using the token never
@@ -2702,6 +2824,18 @@ export class AuthService extends PuterService {
const session: SessionRow | null = rawRow;
if (!session) return { invalid: true };
// A session handed to a first-party page lives no longer than the
// sign-in it was handed out under.
if (session.kind === 'web' && session.parent_session_id) {
const parent = (await this.stores.session.getByUuidAny(
session.parent_session_id,
)) as SessionRow | null;
if (!parent) {
return { reauth: { reason: 'session_revoked', auth_id } };
}
const parentEnded = this.#endedSession(parent, auth_id);
if (parentEnded) return parentEnded;
}
if (!(await deployed)) return { invalid: true };
void this.#touchSession(session.uuid, user.id, ctx);
@@ -2760,7 +2894,11 @@ export class AuthService extends PuterService {
// An events handler's calls are background work, not the user being
// active, and its session never expires, so they record no activity.
if (decoded.worker_name !== EVENTS_WORKER_SESSION_NAME)
// Nor do calls on a debug session, which aren't the user's own.
if (
decoded.worker_name !== EVENTS_WORKER_SESSION_NAME &&
session.kind !== DEBUG_APP_SESSION_KIND
)
void this.#touchSession(session.uuid, user.id, ctx);
const actor = this.#buildAppUnderUserActor(user, app, session);
@@ -2990,7 +3128,13 @@ export class AuthService extends PuterService {
return makeActor({
user: this.#actorUserFromRow(user),
session: session
? { uid: session.uuid, kind: session.kind ?? null }
? {
uid: session.uuid,
kind: session.kind ?? null,
...(session.kind === 'web' && session.parent_session_id
? { parentUid: session.parent_session_id }
: {}),
}
: null,
});
}
+1
View File
@@ -644,6 +644,7 @@ export class OIDCService extends PuterService {
// forwarded chain stays in `audit_metadata.ip_fwd`.
signup_ip_forwarded: clientIp,
signup_user_agent: req?.headers?.['user-agent'] ?? null,
signup_tls_fingerprint: req?.tlsFingerprint ?? null,
signup_origin: req?.headers?.origin,
signup_server: this.config.serverId,
referrer: referrer ?? null,
@@ -399,8 +399,11 @@ export class PuterHomepageService extends PuterService {
// instead of the socket wire.
eventsNotifications: notificationsFoldInEnabled(this.config),
...meta,
// After `meta`, so nothing page-specific can name the opener.
// After `meta`, so nothing page-specific can name the opener or
// the pages a sign-in popup hands a session.
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
first_party_session_origins:
this.config.first_party_session_origins ?? [],
};
// Only ever this request's; without one the GUI asks for its token as
// it always has.
@@ -245,6 +245,7 @@ export class SignupService extends PuterService {
data: input.data,
ip: clientIp,
user_agent: req?.headers?.['user-agent'] ?? null,
tls_fingerprint: req?.tlsFingerprint ?? null,
email,
// The form `email.validate` was given, so the abuse harness can
// find the verdict that hook cached for this address.
@@ -559,6 +560,7 @@ export class SignupService extends PuterService {
// column; the raw chain stays in `audit_metadata`.
signup_ip_forwarded: clientIp,
signup_user_agent: req.headers?.['user-agent'] ?? null,
signup_tls_fingerprint: req.tlsFingerprint ?? null,
signup_origin:
(req.headers?.origin as string | null) ?? null,
signup_server: this.config.serverId,
+87 -3
View File
@@ -60,6 +60,12 @@ export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y
export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite);
export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days
// An app session created on a user's behalf for debugging: hard expiry, never
// slid, and hidden from the user's own session list.
export const DEBUG_APP_SESSION_KIND = 'debug_app';
// `created_via` of a `web` row handed to one of the deployment's own pages
// under another sign-in (its `parent_session_id`).
export const FIRST_PARTY_SESSION_VIA = 'first_party';
// A godmode app's token lapses at most this long after the desktop last asked
// for it, so a closed desktop's tokens die on their own. A token past the max
// age is never renewed again; the next ask replaces it and revokes the old one.
@@ -163,7 +169,8 @@ export class SessionStore extends PuterStore {
* Interactive sign-ins for a user, newest first, for the account's own
* activity view. Revoked rows are included -- a session someone opened and
* closed is exactly what the reader is looking for. Derived kinds (app,
* access token, asset, worker) are not sign-ins and stay out.
* access token, asset, worker) and sessions handed out under another
* sign-in are not sign-ins and stay out.
*
* Uncached and keyset-paginated on `id`: the caller merges this with
* another stream, so it asks for one page at a time rather than the whole
@@ -176,7 +183,7 @@ export class SessionStore extends PuterStore {
async listSignIns(userId, { limit, beforeId = null }) {
const rows = await this.clients.db.read(
'SELECT `id`, `created_at`, `last_ip`, `last_user_agent` FROM `sessions` ' +
"WHERE `user_id` = ? AND `kind` = 'web'" +
"WHERE `user_id` = ? AND `kind` = 'web' AND `parent_session_id` IS NULL" +
(beforeId === null ? '' : ' AND `id` < ?') +
' ORDER BY `id` DESC LIMIT ?',
beforeId === null ? [userId, limit] : [userId, beforeId, limit],
@@ -490,13 +497,19 @@ export class SessionStore extends PuterStore {
* sessions costs a few primary round trips rather than a few per session.
* Returns `{ userId, uuids }` for each user that had rows revoked.
*
* A first-party page's session, revoked under its sign-in, is cascaded from
* in turn: it has derived rows of its own.
*
* @param {string[]} rootUuids
*/
async revokeCascades(rootUuids) {
const roots = [...new Set(rootUuids.filter(Boolean).map(String))];
const queued = new Set(roots);
const uuidsByUser = new Map();
for (let i = 0; i < roots.length; i += REVOKE_CHUNK_SIZE) {
// `roots` grows as it is walked, so each pass takes what is there.
for (let i = 0; i < roots.length; ) {
const chunk = roots.slice(i, i + REVOKE_CHUNK_SIZE);
i += chunk.length;
const placeholders = chunk.map(() => '?').join(', ');
const scope = `(\`uuid\` IN (${placeholders}) OR \`parent_session_id\` IN (${placeholders})) AND \`revoked_at\` IS NULL`;
const scopeParams = [...chunk, ...chunk];
@@ -528,6 +541,14 @@ export class SessionStore extends PuterStore {
const userId = Number(r.user_id);
if (!uuidsByUser.has(userId)) uuidsByUser.set(userId, []);
uuidsByUser.get(userId).push(String(r.uuid));
if (
r.kind === 'web' &&
r.parent_session_id &&
!queued.has(String(r.uuid))
) {
queued.add(String(r.uuid));
roots.push(String(r.uuid));
}
}
}
return [...uuidsByUser].map(([userId, uuids]) => ({ userId, uuids }));
@@ -756,6 +777,57 @@ export class SessionStore extends PuterStore {
return { row, replaced: found ?? null };
}
/**
* The `web` session one of the deployment's own pages runs on under a
* sign-in: the live one for (`parentSessionUid`, `origin`) if there is one,
* else a new row. Revoking the parent cascades here. Concurrent first asks
* can each insert a row; both stay valid.
*
* @param {number} userId - User row id.
* @param {{
* parentSessionUid: string;
* origin: string;
* expiresAt: number;
* meta?: Record<string, unknown>;
* auth_id?: string;
* }} opts
* - `origin` is the page's origin, kept in `meta.first_party_origin`. `meta`
* is the request context, as on any `web` row.
*/
async getOrCreateFirstPartySession(
userId,
{ parentSessionUid, origin, expiresAt, meta = {}, auth_id },
) {
if (!userId || !parentSessionUid || !origin) return null;
const now = nowSeconds();
// One per listed origin under a sign-in, so a handful at most.
const rows = await this.clients.db.pread(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `parent_session_id` = ? AND `created_via` = ? AND `revoked_at` IS NULL ORDER BY `id` DESC LIMIT 20",
[userId, parentSessionUid, FIRST_PARTY_SESSION_VIA],
);
const found = rows
.map((row) => this.#normalizeRow(row))
.find(
(row) =>
row &&
!isExpired(row, now) &&
row.meta?.first_party_origin === origin,
);
if (found) return found;
return this.create(userId, {
kind: 'web',
parent_session_id: parentSessionUid,
created_via: FIRST_PARTY_SESSION_VIA,
meta: { ...meta, first_party_origin: origin },
last_ip: meta.ip ?? null,
last_user_agent: meta.user_agent ?? null,
expires_at: expiresAt,
auth_id,
});
}
/**
* Idempotent "give me the app session for this (user, app)" lookup. Returns
* the existing active app session if one exists, or creates a new one.
@@ -1004,6 +1076,18 @@ export class SessionStore extends PuterStore {
return result?.affectedRows ?? result?.changes ?? 0;
}
/**
* Hard-delete debug app sessions that expired before `cutoff` (unix
* seconds). Cached copies still read as expired, so need no invalidation.
*/
async deleteExpiredDebugAppSessions(cutoff) {
const result = await this.clients.db.write(
'DELETE FROM `sessions` WHERE `kind` = ? AND `expires_at` IS NOT NULL AND `expires_at` < ?',
[DEBUG_APP_SESSION_KIND, cutoff],
);
return result?.affectedRows ?? result?.changes ?? 0;
}
/**
* Bump `last_activity` and slide `expires_at` per the row's kind in a
* single UPDATE. Sliding kinds (web/app/asset) get their `expires_at`
@@ -357,6 +357,28 @@ describe('SessionStore', () => {
});
});
describe('deleteExpiredDebugAppSessions', () => {
it('deletes only expired debug_app rows', async () => {
const user = await makeUser();
const now = Math.floor(Date.now() / 1000);
const mk = async (kind: string, expiresAt: number | null) => {
const { uuid } = await target.create(user.id, {
kind,
expires_at: expiresAt,
});
return uuid;
};
const expired = await mk('debug_app', now - 10);
const live = await mk('debug_app', now + 3600);
const otherKind = await mk('app', now - 10);
expect(await target.deleteExpiredDebugAppSessions(now)).toBe(1);
expect(await rawRow(expired)).toBeNull();
expect(await rawRow(live)).not.toBeNull();
expect(await rawRow(otherKind)).not.toBeNull();
});
});
describe('revokeCascade', () => {
it('revokes the root session and all child sessions', async () => {
const user = await makeUser();
+6 -1
View File
@@ -75,6 +75,8 @@ export interface UserRow {
* "verified a card".
*/
card_fingerprint?: string | null;
/** TLS fingerprint of the request that created the account, if known. */
signup_tls_fingerprint?: string | null;
/**
* Home region this account's data belongs in - a node id from
* `IConfig.servers`, not a storage region. Null for accounts that predate
@@ -531,6 +533,7 @@ export class UserStore extends PuterStore {
signup_ip?: string | null;
signup_ip_forwarded?: string | null;
signup_user_agent?: string | null;
signup_tls_fingerprint?: string | null;
signup_origin?: string | null;
signup_server?: string | null;
referrer?: string | null;
@@ -557,6 +560,7 @@ export class UserStore extends PuterStore {
signup_ip,
signup_ip_forwarded,
signup_user_agent,
signup_tls_fingerprint,
signup_origin,
signup_server,
referrer,
@@ -565,7 +569,7 @@ export class UserStore extends PuterStore {
phone,
requires_phone_verification,
requires_card_verification)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
[
fields.username,
fields.email,
@@ -585,6 +589,7 @@ export class UserStore extends PuterStore {
fields.signup_ip ?? null,
fields.signup_ip_forwarded ?? null,
fields.signup_user_agent ?? null,
fields.signup_tls_fingerprint ?? null,
fields.signup_origin ?? null,
fields.signup_server ?? null,
fields.referrer ?? null,
+54
View File
@@ -1,5 +1,6 @@
import bcrypt from 'bcrypt';
import { mkdtempSync, rmSync } from 'node:fs';
import { ServerResponse, type IncomingMessage } from 'node:http';
import { createServer } from 'node:net';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
@@ -543,3 +544,56 @@ export const withAiCostFactorHook = async <T>(
events.off('ai.cost.factor.*', hook);
}
};
/**
* The most `close` listeners the backend may leave on one response. Request
* tracing adds up to five of its own, and Node warns past ten.
*/
export const RESPONSE_CLOSE_LISTENER_BUDGET = 5;
/**
* Records the most `close` listeners each server response held at once. The
* hook is process-wide, so call `restore` when done.
*/
export const trackResponseCloseListeners = () => {
const peaks = new Map<ServerResponse, number>();
const proto = ServerResponse.prototype as unknown as Record<
string,
(
this: ServerResponse,
event: string | symbol,
listener: unknown,
) => unknown
>;
const methods = ['on', 'addListener', 'prependListener'];
const own = methods.map((m) => Object.getOwnPropertyDescriptor(proto, m));
for (const method of methods) {
const original = proto[method]!;
proto[method] = function (event, listener) {
const result = original.call(this, event, listener);
if (event === 'close') {
const count = this.listenerCount('close');
peaks.set(this, Math.max(peaks.get(this) ?? 0, count));
}
return result;
};
}
return {
/** The highest count on any response to a request `match` accepts. */
peak: (match: (req: IncomingMessage) => boolean): number =>
Math.max(
0,
...[...peaks]
.filter(([res]) => match(res.req))
.map(([, count]) => count),
),
restore: () => {
methods.forEach((method, i) => {
const descriptor = own[i];
if (descriptor)
Object.defineProperty(proto, method, descriptor);
else delete proto[method];
});
},
};
};
+21 -3
View File
@@ -269,7 +269,12 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
}
/**
@@ -807,6 +812,12 @@ interface IConfigOptional {
* forgeable. See https://expressjs.com/en/guide/behind-proxies.html.
*/
trust_proxy: boolean | number | string | string[];
/**
* Request header carrying the client's TLS fingerprint (JA3/JA4), set by a
* proxy that terminates TLS in front of the server. Only set this when that
* proxy overwrites any value the client sends. Unset disables it.
*/
tlsFingerprintHeader?: string;
/** Don't launch browser when starting. */
no_browser_launch: boolean;
/** Disable dev-time frontend webpack watchers. */
@@ -843,6 +854,14 @@ interface IConfigOptional {
* obtain its token via the AuthMe flow instead.
*/
allow_gui_origins?: string[];
/**
* Origins of this deployment's own pages, served apart from the GUI, that
* the GUI's sign-in may hand a session of their own instead of an app token
* (see `AuthService.createFirstPartySessionToken`). Exact origins only.
* Such a page acts as the account, so list only pages this deployment
* builds and serves itself.
*/
first_party_session_origins?: string[];
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
url_signature_secret: string;
/**
@@ -1405,8 +1424,7 @@ export interface ServerDrainInfo {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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>[];
+1
View File
@@ -66,6 +66,7 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'signup_ip',
'signup_ip_forwarded',
'signup_user_agent',
'signup_tls_fingerprint',
'signup_origin',
'signup_server',
'audit_metadata',
+13
View File
@@ -44,6 +44,19 @@ A `Promise` that will resolve to a [`Subdomain`](/Objects/subdomain/) object whe
If a subdomain with the given name already exists, the promise will be rejected with an error. If the path does not exist, the promise will be rejected with an error.
## Cached and uncached URLs
Each site you create is available at two URLs, `<subdomain>.puter.site` and `<subdomain>.puter.host`.
By default, use `<subdomain>.puter.site`. It's served through a CDN and cached, so it loads fast. Changes to your site's files take a few seconds to show up there.
Use `<subdomain>.puter.host` only in the rare case where you need your files exactly as they are right now and can't wait those few seconds. It isn't served through a CDN or cached, so requests to it can be slower and you're charged more for egress. If you aren't sure which one you need, use `puter.site`.
```
<subdomain>.puter.site // cached, use this by default
<subdomain>.puter.host // uncached, only for rare cases
```
## Examples
### Create a simple website displaying "Hello world!"
+12 -58
View File
@@ -24,6 +24,7 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { setFolderView } from '../helpers/folderViewPrefs.js';
import { atPath } from '../helpers/pathElements.js';
import { resolve_typed_path } from '../helpers/expandHomePath.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
@@ -3089,9 +3090,6 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
@@ -3132,12 +3130,7 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
// The folder's sort and layout are applied by refresh_item_container below.
}).catch(() => {
if ( $(el_window).attr('data-path') !== target_path ) {
return;
@@ -3489,27 +3482,7 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) {
sort_order = 'asc';
}
$.ajax({
url: `${window.api_origin }/set_sort_by`,
type: 'POST',
data: JSON.stringify({
sort_by: sort_by,
item_uid: item_uid,
sort_order: sort_order,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
success: function () {
},
});
setFolderView(item_uid, { sort_by, sort_order });
// update the sort_by & sort_order attr of every matching element
$(`[data-uid="${item_uid}"]`).attr({
'data-sort_by': sort_by,
@@ -4987,34 +4960,15 @@ $(document).on('click', '.explore-table-headers-th', function (e) {
});
window.set_layout = function (item_uid, layout) {
$.ajax({
url: `${window.api_origin }/set_layout`,
type: 'POST',
data: JSON.stringify({
item_uid: item_uid,
layout: layout,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
success: function () {
if ( layout === 'details' ) {
let el_window = $(`.window[data-uid="${item_uid}"]`);
if ( el_window.length > 0 ) {
let sort_by = el_window.attr('data-sort_by');
let sort_order = el_window.attr('data-sort_order');
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
}
}
},
});
setFolderView(item_uid, { layout });
if ( layout === 'details' ) {
let el_window = $(`.window[data-uid="${item_uid}"]`);
if ( el_window.length > 0 ) {
let sort_by = el_window.attr('data-sort_by');
let sort_order = el_window.attr('data-sort_order');
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
}
}
};
window.update_details_layout_sort_visuals = function (el_window, sort_by, sort_order) {
+20 -1
View File
@@ -47,12 +47,24 @@ const deviceIconSvg = (session) => {
if ( session.kind === 'worker' ) return ICONS.worker;
if ( session.kind === 'access_token' ) return ICONS.key;
if ( session.kind === 'app' ) return ICONS.app;
if ( firstPartyHost(session) ) return ICONS.globe;
const { os } = parseUserAgent(session.last_user_agent);
if ( os === 'iOS' || os === 'Android' ) return ICONS.phone;
if ( os ) return ICONS.laptop;
return ICONS.globe;
};
// Host of the first-party page a session was handed to under a sign-in, or
// null for any other session.
export const firstPartyHost = (session) => {
if ( typeof session?.first_party_origin !== 'string' ) return null;
try {
return new URL(session.first_party_origin).host || null;
} catch {
return null;
}
};
// Short, human label for the kind pill (falls back to the raw kind).
const kindBadgeLabel = (kind) => {
switch ( kind ) {
@@ -303,7 +315,8 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|| i18n('ui_session_kind_access_token') || 'Access token';
}
if ( session.kind === 'web' ) {
return session.label || i18n('ui_session_kind_web') || 'Browser session';
return session.label || firstPartyHost(session)
|| i18n('ui_session_kind_web') || 'Browser session';
}
return session.label || session.kind || 'Session';
};
@@ -320,6 +333,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
sessionTitle(session),
session.label,
session.kind,
firstPartyHost(session),
session.last_ip,
session.app?.title,
session.app?.name,
@@ -509,6 +523,11 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
b.classList.add('session-widget-badge', `session-widget-badge-${session.kind}`);
b.textContent = kindBadgeLabel(session.kind);
el_badges.appendChild(b);
} else if ( firstPartyHost(session) ) {
const b = document.createElement('span');
b.classList.add('session-widget-badge', 'session-widget-badge-site');
b.textContent = i18n('ui_session_kind_site') || 'Site session';
el_badges.appendChild(b);
}
el_titleline.appendChild(el_badges);
el_main.appendChild(el_titleline);
+5
View File
@@ -3988,6 +3988,11 @@ html.dark-mode .usage-table-fade-overlay {
color: #1d4ed8;
}
.session-widget-badge-site {
background: #dcfce7;
color: #15803d;
}
/* Meta lines */
.session-widget-meta {
display: flex;
+84
View File
@@ -0,0 +1,84 @@
/*
* 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/>.
*/
/**
* Pages of this deployment that are served apart from the GUI sign in with a
* session of their own rather than an app token. The server lists their
* origins (`gui_params.first_party_session_origins`) and mints the session.
*/
const originOf = (value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
};
/**
* Whether a sign-in for the page at `origin` hands over a session.
*
* @param {string} origin
* @param {string[]} [listed]
* @returns {boolean}
*/
export const isFirstPartySessionOrigin = (
origin,
listed = window.gui_params?.first_party_session_origins,
) => {
const wanted = originOf(origin);
if ( !wanted || !Array.isArray(listed) ) return false;
return listed.some(entry => originOf(entry) === wanted);
};
/**
* The token a sign-in hands the page that asked for it: a session of the
* page's own for a first-party page, else the app token the exchange returned.
* Throws when the session can't be minted, since an app token can't run such
* a page.
*
* @param {string} origin - the page's attested origin
* @param {string} appToken
* @param {object} [deps]
* @param {string[]} [deps.listed]
* @param {typeof fetch} [deps.fetchImpl]
* @param {string} [deps.apiOrigin]
* @param {string} [deps.authToken] - the GUI session token
* @returns {Promise<string>}
*/
export const signInTokenForOpener = async (origin, appToken, deps = {}) => {
if ( ! isFirstPartySessionOrigin(origin, deps.listed) ) return appToken;
const fetchImpl = deps.fetchImpl ?? fetch;
const apiOrigin = deps.apiOrigin ?? window.api_origin;
const authToken = deps.authToken ?? window.auth_token;
const response = await fetchImpl(`${apiOrigin}/auth/first-party-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ origin: originOf(origin) }),
});
const body = response.ok ? await response.json() : null;
if ( typeof body?.token !== 'string' || !body.token ) {
throw new Error('first-party session request returned no token');
}
return body.token;
};
@@ -0,0 +1,80 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import { isFirstPartySessionOrigin, signInTokenForOpener } from './firstPartySession.js';
const PAGE = 'https://desk.puter.test';
const listed = [`${PAGE}/`];
const answering = (status, body) => vi.fn(async () => ({
ok: status >= 200 && status < 300,
json: async () => body,
}));
describe('isFirstPartySessionOrigin', () => {
it('matches a listed origin exactly', () => {
expect(isFirstPartySessionOrigin(PAGE, listed)).toBe(true);
expect(isFirstPartySessionOrigin(`${PAGE}/some/path`, listed)).toBe(true);
});
it('matches nothing else', () => {
expect(isFirstPartySessionOrigin('http://desk.puter.test', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://desk.puter.test:8443', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://x.desk.puter.test', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://desk.puter.test.evil.example', listed)).toBe(false);
expect(isFirstPartySessionOrigin('', listed)).toBe(false);
expect(isFirstPartySessionOrigin(PAGE, null)).toBe(false);
expect(isFirstPartySessionOrigin(PAGE, [])).toBe(false);
});
});
describe('signInTokenForOpener', () => {
const deps = (fetchImpl) => ({
listed,
fetchImpl,
apiOrigin: 'https://api.puter.test',
authToken: 'gui-token',
});
it('hands any other page its app token without asking the server', async () => {
const fetchImpl = answering(200, { token: 'session-token' });
const token = await signInTokenForOpener('https://notes.example.com', 'app-token', deps(fetchImpl));
expect(token).toBe('app-token');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('hands a first-party page the session the server mints for its origin', async () => {
const fetchImpl = answering(200, { token: 'session-token' });
const token = await signInTokenForOpener(PAGE, 'app-token', deps(fetchImpl));
expect(token).toBe('session-token');
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.puter.test/auth/first-party-session');
expect(init.headers.Authorization).toBe('Bearer gui-token');
expect(JSON.parse(init.body)).toEqual({ origin: PAGE });
});
it('fails rather than fall back to the app token', async () => {
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(403, { code: 'forbidden' }))))
.rejects.toThrow();
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(200, {}))))
.rejects.toThrow();
});
});
+112
View File
@@ -0,0 +1,112 @@
/*
* 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/>.
*/
/**
* How each folder is shown — its sort and its layout — kept in the user's
* key-value store, one key per folder uid, as `{ sort_by, sort_order, layout }`.
*/
export const LAYOUTS = ['icons', 'list', 'details'];
export const SORT_BYS = ['name', 'modified', 'type', 'size'];
export const SORT_ORDERS = ['asc', 'desc'];
export const DEFAULT_FOLDER_VIEW = Object.freeze({
sort_by: 'name',
sort_order: 'asc',
layout: 'icons',
});
const ALLOWED = { sort_by: SORT_BYS, sort_order: SORT_ORDERS, layout: LAYOUTS };
export const folderViewKey = uid => `folder_view:${uid}`;
/**
* The known preferences in `value`, without anything unrecognized.
*
* @param {unknown} value
* @returns {{ sort_by?: string, sort_order?: string, layout?: string }}
*/
export function pickFolderView(value) {
if ( typeof value === 'string' ) {
try {
value = JSON.parse(value);
} catch {
return {};
}
}
const picked = {};
if ( !value || typeof value !== 'object' ) return picked;
for ( const [field, allowed] of Object.entries(ALLOWED) ) {
if ( allowed.includes(value[field]) ) picked[field] = value[field];
}
return picked;
}
const usableUid = uid => typeof uid === 'string' && uid !== '' && uid !== 'null' && uid !== 'undefined';
// The last stored value this session knows of, per folder, so a change to
// one preference keeps the others it doesn't touch.
const known = new Map();
const kv = () => globalThis.puter.kv;
/**
* How to show a folder: what's stored for it, then what its fsentry carries
* (the server kept a layout there before), then the defaults.
*
* @param {object | null | undefined} fsentry - from `puter.fs.stat()`
* @returns {Promise<{ sort_by: string, sort_order: string, layout: string }>}
*/
export async function getFolderView(fsentry) {
const uid = fsentry?.uid ?? fsentry?.id;
let stored = {};
if ( usableUid(uid) ) {
const read = kv().get(folderViewKey(uid)).then(pickFolderView);
known.set(uid, read.catch(() => ({})));
try {
stored = await read;
} catch ( err ) {
console.warn('Could not load folder view:', err);
}
}
return { ...DEFAULT_FOLDER_VIEW, ...pickFolderView(fsentry), ...stored };
}
/**
* Store some of a folder's preferences; the rest stay as they were.
*
* @param {string} uid
* @param {{ sort_by?: string, sort_order?: string, layout?: string }} changes
* @returns {Promise<void>}
*/
export function setFolderView(uid, changes) {
if ( ! usableUid(uid) ) return Promise.resolve();
const base = known.get(uid)
?? kv().get(folderViewKey(uid)).then(pickFolderView).catch(() => ({}));
const next = base.then(stored => ({ ...stored, ...pickFolderView(changes) }));
known.set(uid, next);
return next
.then(value => kv().set(folderViewKey(uid), value))
.catch(err => console.warn('Could not save folder view:', err));
}
/** Forget what this session knows of stored values; for tests. */
export function resetFolderViewCache() {
known.clear();
}
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_FOLDER_VIEW,
folderViewKey,
getFolderView,
pickFolderView,
resetFolderViewCache,
setFolderView,
} from './folderViewPrefs.js';
let store;
let kv;
beforeEach(() => {
store = new Map();
kv = {
get: vi.fn(async key => store.get(key) ?? null),
set: vi.fn(async (key, value) => {
store.set(key, value);
return true;
}),
};
vi.stubGlobal('puter', { kv });
resetFolderViewCache();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('pickFolderView', () => {
it('keeps known values only', () => {
expect(pickFolderView({
sort_by: 'size',
sort_order: 'sideways',
layout: 'details',
extra: 1,
})).toEqual({ sort_by: 'size', layout: 'details' });
});
it('reads a JSON string and ignores anything else', () => {
expect(pickFolderView('{"sort_order":"desc"}')).toEqual({ sort_order: 'desc' });
expect(pickFolderView('nope')).toEqual({});
expect(pickFolderView(null)).toEqual({});
expect(pickFolderView(7)).toEqual({});
});
});
describe('getFolderView', () => {
it('prefers what is stored, then the fsentry, then the defaults', async () => {
store.set(folderViewKey('dir'), { sort_by: 'modified' });
expect(await getFolderView({ uid: 'dir', layout: 'list', sort_by: 'size' })).toEqual({
sort_by: 'modified',
sort_order: 'asc',
layout: 'list',
});
});
it('falls back when the store can\'t be read', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
kv.get.mockRejectedValue(new Error('offline'));
expect(await getFolderView({ id: 'dir', layout: 'details' }))
.toEqual({ ...DEFAULT_FOLDER_VIEW, layout: 'details' });
});
it('doesn\'t ask the store about a folder without a uid', async () => {
expect(await getFolderView({ uid: 'null' })).toEqual(DEFAULT_FOLDER_VIEW);
expect(await getFolderView(undefined)).toEqual(DEFAULT_FOLDER_VIEW);
expect(kv.get).not.toHaveBeenCalled();
});
});
describe('setFolderView', () => {
it('keeps the preferences a change doesn\'t touch', async () => {
store.set(folderViewKey('dir'), { layout: 'details', sort_by: 'size' });
await setFolderView('dir', { sort_by: 'name', sort_order: 'desc' });
expect(store.get(folderViewKey('dir'))).toEqual({
layout: 'details',
sort_by: 'name',
sort_order: 'desc',
});
});
it('builds on the last change, not on a read that predates it', async () => {
await getFolderView({ uid: 'dir' });
const sort = setFolderView('dir', { sort_by: 'type' });
const layout = setFolderView('dir', { layout: 'list' });
await Promise.all([sort, layout]);
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'type', layout: 'list' });
expect(kv.get).toHaveBeenCalledOnce();
});
it('drops unknown values and folders without a uid', async () => {
await setFolderView('dir', { layout: 'grid', sort_by: 'size' });
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'size' });
await setFolderView('null', { layout: 'list' });
await setFolderView(undefined, { layout: 'list' });
expect(kv.set).toHaveBeenCalledOnce();
});
it('reports a failed save instead of throwing', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
kv.set.mockRejectedValue(new Error('offline'));
await expect(setFolderView('dir', { layout: 'list' })).resolves.toBeUndefined();
expect(warn).toHaveBeenCalled();
});
});
+3 -1
View File
@@ -410,11 +410,13 @@ export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(wi
* the user bought credit, once the dialog closes.
*
* @param {Window} win
* @param {{ appUid?: string }} attribution - the app whose page frames it; an empty one is passed as null
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
export const showFramedTopUp = (win, attribution, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UITopUp({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => closed(result?.toppedUp === true),
});
return dialog.open();
+15 -5
View File
@@ -443,17 +443,27 @@ describe('showFramedTopUp', () => {
let options;
const result = showFramedTopUp(topUpWindow(async (o) => {
options = o;
}), tell);
}), { appUid: 'app-1' }, tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(options.attribution).toEqual({ appUid: 'app-1' });
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ toppedUp: true });
await expect(result).resolves.toBe(true);
});
it('passes no attribution when the framing page resolves to no app', async () => {
let options;
await showFramedTopUp(topUpWindow(async (o) => {
options = o;
o.onClose();
}), {}, vi.fn());
expect(options.attribution).toBeNull();
});
it('resolves false for a close without a purchase', async () => {
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), {}, vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), {}, vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
@@ -462,7 +472,7 @@ describe('showFramedTopUp', () => {
root.setAttribute('aria-modal', 'true');
const result = showFramedTopUp(topUpWindow(async () => {
document.body.append(root);
}), vi.fn());
}), {}, vi.fn());
await vi.advanceTimersByTimeAsync(0);
root.remove();
await vi.advanceTimersByTimeAsync(600);
@@ -472,7 +482,7 @@ describe('showFramedTopUp', () => {
it('rejects when the dialog fails to open', async () => {
await expect(showFramedTopUp(topUpWindow(async () => {
throw new Error('no dialog');
}), vi.fn())).rejects.toThrow('no dialog');
}), {}, vi.fn())).rejects.toThrow('no dialog');
});
});
+22 -14
View File
@@ -23,6 +23,7 @@ import item_icon from './itemIcon.js';
import list_all_shared from './listAllShared.js';
import parse_item_metadata from './parseItemMetadata.js';
import { remember_shared_roots } from './sharedAccess.js';
import { getFolderView } from './folderViewPrefs.js';
const refresh_item_container = function (el_item_container, options) {
// start a transaction
@@ -105,9 +106,6 @@ const refresh_item_container = function (el_item_container, options) {
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
@@ -115,17 +113,27 @@ const refresh_item_container = function (el_item_container, options) {
$(el_window).find('.window-navbar-path-input').val(container_path);
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
}
$(el_item_container).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_item_container).attr('data-sort_order', fsentry.sort_order ?? 'asc');
// update layout
if ( el_window && el_window.length > 0 )
{
window.update_window_layout(el_window, fsentry.layout);
}
//
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
return getFolderView(fsentry).then(view => {
if ( ! is_current() ) {
return;
}
// Items may already be in, placed by the sort the container had before.
const resort = $(el_item_container).attr('data-sort_by') !== view.sort_by
|| $(el_item_container).attr('data-sort_order') !== view.sort_order;
$(el_item_container).attr('data-sort_by', view.sort_by);
$(el_item_container).attr('data-sort_order', view.sort_order);
if ( el_window && el_window.length > 0 ) {
$(el_window).attr('data-sort_by', view.sort_by);
$(el_window).attr('data-sort_order', view.sort_order);
window.update_window_layout(el_window, view.layout);
if ( view.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, view.sort_by, view.sort_order);
}
}
if ( resort ) {
window.sort_items(el_item_container, view.sort_by, view.sort_order);
}
});
}).catch(() => {
// The listing request below reports the failure to the user.
});
@@ -229,3 +229,57 @@ describe('refresh_item_container after the window moves to another folder', () =
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
});
});
describe('refresh_item_container and the folder\'s stored view', () => {
let views;
beforeEach(() => {
views = {};
puter.kv = {
get: vi.fn((key) => {
views[key] ??= deferred();
return views[key].promise;
}),
};
puter.fs.stat.mockImplementation(async ({ path }) => ({ id: path.slice(1), name: path.slice(1) }));
puter.fs.readdir.mockResolvedValue([]);
vi.spyOn(window, 'sort_items');
vi.spyOn(window, 'update_window_layout');
document.body.innerHTML =
'<div class="window"><div class="item-container" data-sort_by="name" data-sort_order="asc"></div></div>';
});
afterEach(() => {
delete puter.kv;
});
it('lays out and re-sorts the folder by what is stored for it', async () => {
navigate('/docs');
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
views['folder_view:docs'].resolve({ sort_by: 'size', sort_order: 'desc', layout: 'details' });
await vi.waitFor(() => expect(window.sort_items).toHaveBeenCalledWith(
expect.anything(), 'size', 'desc',
));
expect($('.item-container').attr('data-sort_by')).toBe('size');
expect($('.window').attr('data-sort_order')).toBe('desc');
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'details');
});
it('ignores the view of a folder the window has left', async () => {
navigate('/docs');
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
navigate('/pics');
await vi.waitFor(() => expect(views['folder_view:pics']).toBeDefined());
views['folder_view:pics'].resolve(null);
views['folder_view:docs'].resolve({ sort_by: 'size', layout: 'list' });
await vi.waitFor(() => expect(window.update_window_layout).toHaveBeenCalledOnce());
await new Promise((r) => setTimeout(r, 0));
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'icons');
expect(window.update_window_layout).not.toHaveBeenCalledWith(expect.anything(), 'list');
expect($('.item-container').attr('data-sort_by')).toBe('name');
expect(window.sort_items).not.toHaveBeenCalledWith(expect.anything(), 'size', expect.anything());
});
});
+1
View File
@@ -701,6 +701,7 @@ const en = {
ui_session_expires: 'Expires',
ui_session_kind_access_token: 'Access token',
ui_session_kind_app: 'App session',
ui_session_kind_site: 'Site session',
ui_session_kind_web: 'Browser session',
ui_session_kind_worker: 'Worker',
ui_session_last_active: 'Last active',
+3 -1
View File
@@ -61,7 +61,9 @@ describe('the popup token hand-off in postAuthActions', () => {
const steps = [
postAuthActions.indexOf('fetch(`${window.api_origin}/login/set`'),
postAuthActions.indexOf('runsUserAppTokenExchange(action)'),
postAuthActions.search(/success: true,\s+token: data\.token/),
postAuthActions.search(
/success: true,\s+token: await signInTokenForOpener\(origin, data\.token\)/,
),
];
for (const step of steps) {
expect(step).toBeGreaterThan(gate);
+17 -5
View File
@@ -88,6 +88,7 @@ import item_icon from './helpers/itemIcon.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { signInTokenForOpener } from './helpers/firstPartySession.js';
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
import { guiTokenBootStep, pageRenderedForThisLoad, sameTokenClaims } from './helpers/guiToken.js';
import { parse_url_paths } from './helpers/urlPaths.js';
@@ -529,7 +530,7 @@ const postAuthActions = async (action) => {
window.opener?.postMessage({
msg: 'puter.token',
success: true,
token: data.token,
token: await signInTokenForOpener(origin, data.token),
app_uid: data.app_uid,
username: window.user.username,
msg_id: msg_id,
@@ -1088,12 +1089,17 @@ const postAuthActions = async (action) => {
// -------------------------------------------------------------------------------------
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
// which hears whether the user bought credit once the dialog closes.
// which hears whether the user bought credit once the dialog closes. The app it credits
// is the one the framing page's origin resolves to.
// -------------------------------------------------------------------------------------
if ( action === 'top-up' && window.embedded_in_frame ) {
let toppedUp;
try {
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
toppedUp = await showFramedTopUp(
window,
window.host_app_uid ? { appUid: window.host_app_uid } : {},
window.tell_popup_frame,
);
} catch (e) {
// A dialog that failed to open leaves the page to open the top-up in a tab.
console.error('top-up action failed', e);
@@ -2861,7 +2867,10 @@ window.initgui = async function (options) {
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
token: await signInTokenForOpener(
window.openerOrigin,
data.token,
),
username: window.user.username,
app_uid: data.app_uid,
},
@@ -2948,7 +2957,10 @@ window.initgui = async function (options) {
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
token: await signInTokenForOpener(
window.openerOrigin,
data.token,
),
username: window.user.username,
app_uid: data.app_uid,
},
+56 -4
View File
@@ -104,6 +104,25 @@ class Lock {
// (using defaultGUIOrigin breaks locally-hosted apps)
const PROD_ORIGIN = 'https://puter.com';
// The desktops Puter itself serves. An app learns which one launched it from
// the URL (`puter.origin`), and only one of these is believed: the GUI origin
// is what every message from the desktop is checked against, so taking an
// arbitrary one would let any page that frames an app drive it.
const FIRST_PARTY_GUI_ORIGINS = new Set([
'https://puter.com',
'https://desktop.puter.com',
]);
/** `value`'s origin when it is a first-party desktop, otherwise null. */
const firstPartyGUIOrigin = (value) => {
try {
const { origin } = new URL(value);
return FIRST_PARTY_GUI_ORIGINS.has(origin) ? origin : null;
} catch {
return null;
}
};
// localStorage key for the auth token. The retired key below is never read —
// only deleted, so a token from before the format change doesn't linger in a
// visitor's browser (see `discardRetiredAuthToken_`).
@@ -213,6 +232,9 @@ export class Puter {
#defaultAPIOrigin = 'https://api.puter.com';
#defaultGUIOrigin = 'https://puter.com';
// In app mode, the first-party desktop that launched this app, when it
// isn't the build's default one.
#launchGUIOrigin = null;
/** @returns {string} */
get defaultAPIOrigin() {
@@ -230,6 +252,7 @@ export class Puter {
get defaultGUIOrigin() {
return (
globalThis.PUTER_ORIGIN ||
this.#launchGUIOrigin ||
globalThis.PUTER_ORIGIN_ENV ||
this.#defaultGUIOrigin
);
@@ -277,6 +300,15 @@ export class Puter {
*/
openerLaunchItem_ = null;
/**
* Whether this is a tab the GUI opened for an app and handed a session
* token, which it then keeps in sessionStorage the way a framed app does.
*
* @internal
* @type {boolean}
*/
appTabSession_ = false;
// Holds the unique app instance ID that is provided by the host environment
appInstanceID;
@@ -625,6 +657,15 @@ export class Puter {
this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`;
}
// An app launched by a first-party desktop other than puter.com
// (e.g. desktop.puter.com) talks to that desktop: its messages come
// from there, and replies meant only for it go there.
if (this.env === 'app') {
this.#launchGUIOrigin = firstPartyGUIOrigin(
URLParams.get('puter.origin'),
);
}
// === START :: Logger ===
// Basic logger replacement (console-based)
@@ -751,6 +792,9 @@ export class Puter {
// the SDK needs to show a dialog to the user to ask for permission to access their Puter account.
else if (this.env === 'web') {
try {
// A godmode app reloading in the tab the GUI opened it in;
// only that handoff stores a session token on this origin.
const godmodeToken = this.readStoredSessionToken_();
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
);
@@ -763,7 +807,10 @@ export class Puter {
const boundOrigin = this.normalizeStringCandidate(
localStorage.getItem(STORAGE_KEY_ORIGIN_V2),
);
if (
if (godmodeToken) {
this.appTabSession_ = true;
this.setAuthToken(godmodeToken);
} else if (
storedToken &&
this._storedTokenUsableForCurrentOrigin(boundOrigin)
) {
@@ -800,7 +847,12 @@ export class Puter {
launch.item,
);
}
if (launch?.token) this.setAuthToken(launch.token);
if (launch?.token) {
this.appTabSession_ = this.isUserSessionToken_(
launch.token,
);
this.setAuthToken(launch.token);
}
});
}
@@ -1041,10 +1093,10 @@ export class Puter {
// If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage.
// A user session token (a godmode app's launch token) never goes there:
// an app keeps it in sessionStorage so a frame reload still has it.
// an app keeps it in sessionStorage so a reload still has it.
if (this.env === 'web' || this.env === 'app') {
this.storeSessionToken_(
this.env === 'app' &&
(this.env === 'app' || this.appTabSession_) &&
this.isUserSessionToken_(normalizedAuthToken)
? normalizedAuthToken
: null,
+152
View File
@@ -240,6 +240,88 @@ describe('app-mode launch token', () => {
});
});
describe('GUI origin of an app', () => {
const DESKTOP = 'https://desktop.puter.com';
const launchedBy = (origin) =>
bootApp(
`${LAUNCH}${APP_TOKEN}&puter.origin=${encodeURIComponent(origin)}`,
);
/** Sends `data` to the app as its parent at `origin` would. */
const fromParent = async (origin, data) => {
window.dispatchEvent(
new MessageEvent('message', {
origin,
source: globalThis.parent,
data,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
};
const closeAnswers = () =>
globalThis.parent.postMessage.mock.calls
.map(([message]) => message)
.filter((message) => message?.original_msg_id === 'close-1');
it('is the first-party desktop that launched it', async () => {
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(DESKTOP);
expect((await launchedBy('https://puter.com')).defaultGUIOrigin).toBe(
'https://puter.com',
);
});
it('stays puter.com for any other launcher', async () => {
for (const origin of [
'https://evil.example',
'https://desktop.puter.com.evil.example',
'https://x.desktop.puter.com',
'http://desktop.puter.com',
'not a url',
]) {
expect((await launchedBy(origin)).defaultGUIOrigin).toBe(
'https://puter.com',
);
}
expect((await bootApp(`${LAUNCH}${APP_TOKEN}`)).defaultGUIOrigin).toBe(
'https://puter.com',
);
});
it('yields to an explicit PUTER_ORIGIN', async () => {
globalThis.PUTER_ORIGIN = 'https://self-hosted.test';
try {
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(
'https://self-hosted.test',
);
} finally {
delete globalThis.PUTER_ORIGIN;
}
});
it('is not taken from the URL of a top-level page', async () => {
const puter = await bootWeb(
`?puter.app_instance_id=i&puter.origin=${encodeURIComponent(DESKTOP)}`,
);
expect(puter.env).not.toBe('app');
expect(puter.defaultGUIOrigin).toBe('https://puter.com');
});
it('answers windowWillClose from that desktop and no other', async () => {
await launchedBy(DESKTOP);
const willClose = { msg: 'windowWillClose', msg_id: 'close-1' };
await fromParent('https://evil.example', willClose);
await fromParent('https://puter.com', willClose);
expect(closeAnswers()).toEqual([]);
await fromParent(DESKTOP, willClose);
expect(closeAnswers()).toEqual([
expect.objectContaining({ msg: true, original_msg_id: 'close-1' }),
]);
});
});
describe('token adoption on a top-level page', () => {
it('takes a token from a window it opened, and from nothing else', async () => {
const puter = await bootWeb('');
@@ -286,6 +368,76 @@ describe('web-mode stored token', () => {
});
});
describe('godmode app in a tab the GUI opened', () => {
const TAB = '?puter.app_instance_id=i';
/** Boots a top-level tab whose GUI opener hands it `token`. */
const bootTab = async (token) => {
const opener = {
closed: false,
postMessage: (data, guiOrigin) =>
window.dispatchEvent(
new MessageEvent('message', {
origin: guiOrigin,
source: opener,
data: {
msg: 'puter.launch',
appInstanceID: data.appInstanceID,
token,
},
}),
),
};
Object.defineProperty(globalThis, 'opener', {
value: opener,
configurable: true,
writable: true,
});
const puter = await bootWeb(TAB);
await puter.openerLaunch_;
return puter;
};
afterEach(() => {
globalThis.opener = null;
});
it('keeps the handed-over token in sessionStorage, so a reload has it', async () => {
const puter = await bootTab(GODMODE_TOKEN);
expect(puter.env).toBe('web');
expect(puter.authToken).toBe(GODMODE_TOKEN);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
// The opener was cut off, so the reload has only what it stored.
expect(globalThis.opener).toBeNull();
const reloaded = await bootWeb(TAB);
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
});
it('reloads on the godmode token over an app token stored earlier', async () => {
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com');
await bootTab(GODMODE_TOKEN);
const reloaded = await bootWeb(TAB);
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
});
it('drops the sessionStorage copy on sign-out', async () => {
const puter = await bootTab(GODMODE_TOKEN);
puter.resetAuthToken();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('still keeps an app token in localStorage only', async () => {
await bootTab(APP_TOKEN);
expect(localStorage.getItem(STORAGE_KEY)).toBe(APP_TOKEN);
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('GUI cache refresh', () => {
it('keeps one refresh loop however often the token is set', async () => {
const puter = await bootApp(`${LAUNCH}${APP_TOKEN}`);
+1 -1
View File
@@ -1889,7 +1889,7 @@ export class UIModule extends EventListener {
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin, appIDOf(puter)), 'This website wants to open the Puter top-up page.');
return { toppedUp: false };
})();
this.#topUpFrame = showing;
@@ -186,6 +186,16 @@ describe('ui.showTopUp_ on a signed-in first-party page', () => {
});
describe('ui.showTopUp_ elsewhere on the web', () => {
it('names the app the page runs as, so the dashboard can credit it', async () => {
fakePuter({ guiOrigin: 'https://puter.com' });
globalThis.puter.appID = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
await makeUI().showTopUp_();
expect(window.open).toHaveBeenCalledWith(
'https://puter.com/dashboard/?topup=&topup_app=app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10#usage',
'_blank',
);
});
it.each([
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
['a signed-out first-party page', { signedIn: false }],
+3 -1
View File
@@ -32,11 +32,13 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
* The dashboard page that opens the top-up flow, for buying add-on credit.
*
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
* @param {string} [appID] The app whose page asked for the top-up.
* @returns {string}
*/
export const topUpUrl = (guiOrigin) => {
export const topUpUrl = (guiOrigin, appID) => {
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
url.searchParams.set('topup', '');
if ( appID ) url.searchParams.set('topup_app', appID);
url.hash = 'usage';
return url.href;
};