Compare commits

...
Author SHA1 Message Date
Neal ShahandClaude Opus 5.5 84b82e59ae fix(workers): name length cap, sandbox app freed on delete, no app opens from workers
- Worker names are capped at 50 characters, the most a `workers.puter.<name>`
  subdomain row holds. A longer name is a 400 before any token is minted,
  instead of a 500 from the insert. The publish dialog caps at 50 too.
- Deleting a worker deletes its `sandbox-<name>` app when nothing else runs
  as it, so the name can be reused by another user. A sandbox app owned by
  someone else rejects with `sandbox_not_owned` instead of a TypeError.
- POST /rao records nothing for a worker or events handler actor, and the
  worker runtimes' clients no longer ask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 15:16:48 -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
59 changed files with 1516 additions and 43 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;
@@ -21,6 +21,7 @@ import {
isAccessTokenActor,
isAccountContext,
isAppActor,
viaWorkerOf,
} from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
@@ -201,6 +202,13 @@ export class AppController extends PuterController {
},
async (req, res) => {
const actor = req.actor;
// Server-side code running as the account (a worker, an events
// handler) is not someone opening the app. Answered as success
// so deployed workers, which still ask, don't log a failure.
if (viaWorkerOf(actor)) {
res.json({});
return;
}
const bodyAppUid = req.body?.app_uid;
const callerAppUid = actor?.effectiveApp?.uid;
const app_uid =
@@ -1143,6 +1143,34 @@ describe('AppController POST /rao actor gating', () => {
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
});
it('records nothing for a worker running as the app', async () => {
const owner = await makeUser();
const app = await createApp(owner.actor);
const workerActor = makeActor({
...owner.actor,
app: { uid: app.uid as string },
session: { uid: uuidv4(), kind: 'worker' },
} as unknown as Actor);
const { res, captured } = makeRes();
await withActor(workerActor, () =>
callRoute(
'post',
'/rao',
makeReq({ body: {}, actor: workerActor }),
res,
),
);
expect(captured.body).toEqual({});
await server.controllers.apps.drainPendingAppOpens();
const rows = (await server.clients.db.read(
'SELECT `app_uid` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?',
[app.uid, owner.userId],
)) as unknown[];
expect(rows).toHaveLength(0);
});
it("refuses to let an app actor record another app's open", async () => {
const { actor } = await makeUser();
const own = await createApp(actor);
@@ -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,
},
);
+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 -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-chat/types.js';
import { promoteStopForToolCalls } from '../../drivers/ai-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 ----------------------------------------------------
+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);
@@ -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);
@@ -1949,3 +1949,66 @@ describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
);
});
});
describe('WorkerDriver teardown of a worker’s sandbox app', () => {
const SANDBOX_URL = 'https://worker-sandbox.puter.com/';
/** A worker bound to an app made the way puter.js makes its sandbox. */
const deploySandboxed = async ({
appName,
indexUrl = SANDBOX_URL,
}: { appName?: (worker: string) => string; indexUrl?: string } = {}) => {
const { user, actor } = await makeUser();
const name = `sbx-${user.username}`;
const app = await server.stores.app.create(
{
name: appName ? appName(name) : `sandbox-${name}`,
title: name,
index_url: indexUrl,
},
{ ownerUserId: user.id },
);
const path = `/${user.username}/sbx.js`;
await writeSource(actor, user.id, path, 'export default {}');
await inCtx(actor, () =>
target.create({ appId: app.uid, workerName: name, filePath: path }),
);
return { user, actor, app, name, path };
};
it('deletes the sandbox app with the worker, freeing its name', async () => {
const { actor, app, name } = await deploySandboxed();
await inCtx(actor, () => target.destroy({ workerName: name }));
expect(await server.stores.app.getByUid(app.uid)).toBeFalsy();
expect(await server.stores.app.getByName(`sandbox-${name}`)).toBeFalsy();
});
it('keeps the app while another worker still runs as it', async () => {
const { user, actor, app, name, path } = await deploySandboxed();
const other = `${name}-2`;
await inCtx(actor, () =>
target.create({ appId: app.uid, workerName: other, filePath: path }),
);
await inCtx(actor, () => target.destroy({ workerName: name }));
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
expect(
await server.stores.subdomain.getBySubdomain(`workers.puter.${other}`),
).toMatchObject({ user_id: user.id });
});
it('keeps an app that is not named for the worker', async () => {
const { actor, app, name } = await deploySandboxed({
appName: (worker) => `mine-${worker}`,
});
await inCtx(actor, () => target.destroy({ workerName: name }));
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
});
it('keeps a sandbox-named app that points somewhere else', async () => {
const { actor, app, name } = await deploySandboxed({
indexUrl: 'https://example.com/',
});
await inCtx(actor, () => target.destroy({ workerName: name }));
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
});
});
@@ -26,6 +26,7 @@ import { resolveCallableMethods } from '../meta.js';
import {
INTERNAL_ADMIN_DESTROY,
INTERNAL_ADMISSION_BYPASS,
WORKER_NAME_MAX_LENGTH,
} from './WorkerDriver.js';
import type { WorkerDriver } from './WorkerDriver.js';
@@ -164,6 +165,33 @@ describe('WorkerDriver', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects names too long for the worker subdomain', async () => {
// `workers.puter.` + 51 characters overflows the 64-character column.
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'a'.repeat(WORKER_NAME_MAX_LENGTH + 1),
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('accepts a name at the length limit', async () => {
// 503 is the missing Cloudflare config, past the name checks.
expect(WORKER_NAME_MAX_LENGTH).toBe(50);
await expect(
inCtx(() =>
target.create({
appId: 'test-app',
workerName: 'a'.repeat(WORKER_NAME_MAX_LENGTH),
filePath: '/test.js',
}),
),
).rejects.toMatchObject({ statusCode: 503 });
});
it('allows valid worker names with underscores and dashes', async () => {
// This will fail at the CF config check (503) rather than the
// name validation (400), proving the name was accepted.
@@ -58,6 +58,18 @@ const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
export const WORKER_COMPATIBILITY_DATE = '2025-07-15';
export const WORKER_COMPATIBILITY_FLAGS = ['global_fetch_strictly_public'];
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
/** `subdomains.subdomain` is varchar(64) and holds `workers.puter.<name>`. */
export const WORKER_NAME_MAX_LENGTH = 64 - WORKER_SUBDOMAIN_PREFIX.length;
/** Puter.js names a worker's default sandbox app `sandbox-<workerName>`. */
const SANDBOX_APP_PREFIX = 'sandbox-';
/** The page a sandbox app points at, served on the `worker-sandbox` subdomain. */
const isWorkerSandboxUrl = (url: unknown): boolean => {
try {
return new URL(String(url)).hostname.split('.')[0] === 'worker-sandbox';
} catch {
return false;
}
};
/** Cap on ordinary workers while user-scope handlers are off. */
const MAX_WORKERS_PER_USER = 100;
@@ -434,6 +446,15 @@ export class WorkerDriver extends PuterDriver {
{ legacyCode: 'bad_request' },
);
}
// A deploy target names its own script, so only a worker that becomes
// a subdomain row is bound by the column's width.
if (!deployTarget && workerName.length > WORKER_NAME_MAX_LENGTH) {
throw new HttpError(
400,
`Worker name must be at most ${WORKER_NAME_MAX_LENGTH} characters`,
{ legacyCode: 'bad_request' },
);
}
this.#rejectReserved(workerName);
const subdomainName = `${WORKER_SUBDOMAIN_PREFIX}${workerName}`;
@@ -778,6 +799,50 @@ export class WorkerDriver extends PuterDriver {
} catch {
// Non-critical.
}
await this.#releaseSandboxApp(row, workerName);
}
/**
* Delete the `sandbox-<name>` app puter.js made for this worker, so the
* next owner of the worker name can make their own. Only when nothing else
* runs as it: deleting an app cascades to every row bound to it.
*/
async #releaseSandboxApp(
row: SubdomainRow,
workerName: string,
): Promise<void> {
if (row.app_owner === null || row.app_owner === undefined) return;
try {
const app = await this.stores.app.getById(Number(row.app_owner));
if (
!app ||
app.protected ||
app.name !== `${SANDBOX_APP_PREFIX}${workerName}` ||
Number(app.owner_user_id) !== Number(row.user_id) ||
!isWorkerSandboxUrl(app.index_url)
)
return;
const stillBound = await this.stores.subdomain.listAppCascade({
appId: Number(app.id),
});
if (stillBound.length > 0) return;
await this.stores.app.delete(Number(app.id));
this.clients.event.emit(
'app.changed',
{
app_uid: app.uid,
app: null,
old_app: app,
action: 'deleted',
},
{},
);
} catch (e) {
console.error(
`[workers] sandbox app cleanup failed for ${workerName}`,
e,
);
}
}
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
+5 -1
View File
@@ -625,7 +625,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');
+57 -2
View File
@@ -31,6 +31,7 @@ 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,
@@ -787,6 +788,8 @@ export class AuthService extends PuterService {
const visible = [...live.values()].filter(
(row) =>
row.kind !== 'asset' &&
// Operator-created debug sessions are never the user's own.
row.kind !== DEBUG_APP_SESSION_KIND &&
(row.kind !== 'access_token' ||
!row.parent_session_id ||
live.has(row.parent_session_id)),
@@ -1075,7 +1078,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),
);
}
@@ -1858,6 +1866,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
@@ -2760,7 +2811,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);
+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,
@@ -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,
@@ -60,6 +60,9 @@ 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';
// 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.
@@ -1004,6 +1007,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';
@@ -525,3 +526,56 @@ export const lagReadReplica = async (
},
};
};
/**
* 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];
});
},
};
};
+6
View File
@@ -807,6 +807,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. */
+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!"
+3 -1
View File
@@ -23,7 +23,7 @@ puter.workers.create(workerName, filePath, options)
<div class="info">Workers cannot be larger than <strong>10MB</strong>.</div>
#### `workerName` (String)(Required)
The name for the worker. It can contain letters, numbers, hyphens, and underscores.
The name for the worker. It can contain letters, numbers, hyphens, and underscores, and can be up to 50 characters long. A longer name is rejected with `400 bad_request`.
#### `filePath` (String)(Required)
The path to a JavaScript file in your Puter account that contains your [router](/Workers/router/) code.
@@ -48,6 +48,8 @@ A `Promise` that resolves to a [`WorkerDeployment`](/Objects/workerdeployment) o
On failure, throws an `Error` with the reason.
With the default sandbox, if another user already owns the `sandbox-<workerName>` app, the call rejects with `{ message, code: 'sandbox_not_owned' }`. To deploy anyway, choose another worker name or pass `{ sandbox: false }`.
## Worker identity and shared state
Every worker runs as some app, and that identity decides which [`puter.kv`](/KV/) namespace and which `AppData` directory the worker reaches. Two workers running as the same app read and write **the same** KV keys and the same files.
+2
View File
@@ -6,6 +6,8 @@ platforms: [websites, apps, nodejs, workers]
Deletes an existing worker and stops its execution.
If the worker runs in its own `sandbox-<workerName>` app, that app is deleted with it, unless another worker still runs as it. A worker you create later under the same name gets a new sandbox app, so it starts with an empty KV namespace and AppData directory.
## Syntax
```js
+2
View File
@@ -408,6 +408,8 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
| Concurrent worker calls | 10 | 5 | 3 |
| Concurrent deploys | 5 | 2 | 2 |
A worker's name may be up to 50 characters. A [`puter.workers.create()`](/Workers/create/) call with a longer name is rejected with `400 bad_request`.
### Apps
| Operation | Paid | Free | Anonymous |
+3 -1
View File
@@ -31,12 +31,14 @@
// Either name becomes a hostname label, which DNS caps at 63; the backend
// takes a 64-character website name, but nothing can reach it.
const LABEL_MAX_LENGTH = 63;
// A worker is stored as `workers.puter.<name>` in a 64-character column.
const WORKER_NAME_MAX_LENGTH = 50;
/** @type {{ website: PublishNameRule, worker: PublishNameRule }} */
export const PUBLISH_NAME_RULES = {
// A website name can't hold an underscore, so it separates words too.
website: { separators: /[\s_]+/g, invalid: /[^a-z0-9-]/g, maxLength: LABEL_MAX_LENGTH },
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: LABEL_MAX_LENGTH },
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: WORKER_NAME_MAX_LENGTH },
};
/**
+5 -2
View File
@@ -57,8 +57,11 @@ describe('finalizePublishName', () => {
expect(finalizePublishName('!!! ', website)).toBe('');
});
it('caps the length at a hostname label\'s 63', () => {
it('caps a website name at a hostname label\'s 63', () => {
expect(finalizePublishName('a'.repeat(80), website)).toHaveLength(63);
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(63);
});
it('caps a worker name at the 50 its subdomain row can hold', () => {
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(50);
});
});
+14 -1
View File
@@ -316,6 +316,14 @@ export class Puter {
*/
socketEnabled = globalThis.puter_socket_enabled !== false;
/**
* @internal
* Whether this client reports an app open (`/rao`) when it authenticates.
* Seeded off `puter_report_app_open`, which the worker runtimes set to
* `false`: code running as the account isn't a visit.
*/
reportsAppOpen = globalThis.puter_report_app_open !== false;
/**
* Puter.js Modules
*
@@ -865,7 +873,12 @@ export class Puter {
// when running as a Puter app (i.e. within an iframe in the GUI).
// Same call opted out of by `socketEnabled: false` — that flag means
// this client is a disposable per-invocation one, not a real visit.
if (this.env === 'gui' || this.env === 'app' || !this.socketEnabled) {
if (
this.env === 'gui' ||
this.env === 'app' ||
!this.socketEnabled ||
!this.reportsAppOpen
) {
return;
}
+39
View File
@@ -376,3 +376,42 @@ describe('GUI message listener', () => {
}
});
});
describe('app-open reporting', () => {
/** URLs opened through XHR, the transport `fetchUrl` uses. */
let opened;
beforeEach(() => {
opened = [];
globalThis.XMLHttpRequest = class extends StubXHR {
open(_method, url) {
opened.push(String(url));
}
};
});
/** Starts the report (never answered) and waits for its request. */
const raoRequested = async (puter) => {
void puter.request_rao_();
for (let i = 0; i < 50; i++) {
if (opened.some((url) => url.endsWith('/rao'))) return true;
await new Promise((resolve) => setTimeout(resolve, 10));
}
return false;
};
it('reports an open from a page', async () => {
const puter = await bootWeb('');
expect(await raoRequested(puter)).toBe(true);
});
it('reports nothing from a client a worker runtime built', async () => {
globalThis.puter_report_app_open = false;
try {
const puter = await bootWeb('');
expect(puter.reportsAppOpen).toBe(false);
expect(await raoRequested(puter)).toBe(false);
} finally {
delete globalThis.puter_report_app_open;
}
});
});
+6 -2
View File
@@ -88,8 +88,12 @@ export class WorkersHandler extends PuterModule {
} catch ( e ) {
sandboxApp = await this.puter.apps.create(`sandbox-${ workerName }`, 'https://worker-sandbox.puter.com/');
}
if ( sandboxApp.owner.uuid !== user.uuid ) {
throw new Error(`Sandbox context is not owned by you! This worker's sandbox is currently owned by: ${ sandboxApp.owner.username }`);
// Another user's app comes back without `owner`.
if ( sandboxApp.owner?.uuid !== user.uuid ) {
throw {
message: `The sandbox app 'sandbox-${ workerName }' belongs to another user. Choose another worker name, or pass { sandbox: false }.`,
code: 'sandbox_not_owned',
};
}
appId = sandboxApp.uid;
}
@@ -369,4 +369,44 @@ export default suite('workers', {
const worker = await t.puter.workers.get('workers-suite-delete');
t.assert.ok(!worker, 'deleted worker should no longer be returned');
},
'delete removes the worker sandbox app, freeing its name': async (t) => {
const name = 'workers-suite-delete-sbx';
await deployWorker(t, name);
t.assert.equal(await appExists(t, `sandbox-${name}`), true);
await t.puter.workers.delete(name);
t.assert.equal(
await appExists(t, `sandbox-${name}`),
false,
'the sandbox app should go with the worker',
);
},
'create refuses a sandbox app another user owns': async (t) => {
const name = 'workers-suite-sbx-taken';
const res = await fetch(`${t.env.apiOrigin}/drivers/call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${t.env.users.other.token}`,
Origin: t.env.apiOrigin,
},
body: JSON.stringify({
interface: 'puter-apps',
method: 'create',
args: {
object: {
name: `sandbox-${name}`,
title: name,
index_url: 'https://worker-sandbox.puter.com/',
},
},
}),
});
t.assert.equal(res.status, 200, 'the other user owns the sandbox app');
const err = (await t.assert.rejects(() => deployWorker(t, name))) as {
code?: string;
};
t.assert.equal(err.code, 'sandbox_not_owned');
},
});
@@ -42,6 +42,9 @@ globalThis.init_puter_portable = (auth, apiOrigin, type, options = {}) => {
if (options.socket === false) {
goodContext.puter_socket_enabled = false;
}
// Server-side code running as the account is never someone opening
// the app, so no client built here reports an app open.
goodContext.puter_report_app_open = false;
// The fetch this client's own requests go through, for a caller that
// watches how Puter's API answered them.
if (typeof options.fetch === 'function') {