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
15 changed files with 309 additions and 7 deletions

No files matched your search

@@ -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);
@@ -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> {
+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') {