mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b82e59ae | ||
|
|
4ad53899da | ||
|
|
c253b7e05e | ||
|
|
647995133e | ||
|
|
9d3a049988 |
No files matched your search
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user