mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b82e59ae |
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> {
|
||||
|
||||
@@ -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