Compare commits

..
28 Commits
Author SHA1 Message Date
Daniel Salazar 40d1c998bb feat: pass args to all events (#3248)
* feat: pass args to all events

* fix: alias app joining

* fix: actor in event
2026-06-10 14:13:04 -07:00
Daniel Salazar b4837d49b6 perf: some perf gains from fable (#3249) 2026-06-10 13:26:35 -07:00
ProgrammerIn-wonderland e23042b42e add preperation for reputation score persistence (#3250) 2026-06-10 16:09:42 -04:00
Daniel Salazar 240a733285 sec: misc fable hardening (#3244)
* sec: misc fable hardening

* more fixes

* more fixes

* fix: cors issue
2026-06-10 11:19:41 -07:00
Reynaldi Chernando bb202be12c Minor clarification local webserver in deployment (#3247) 2026-06-10 23:04:42 +07:00
Reynaldi Chernando 8c4cb601c9 Add cli docs (#3246) 2026-06-10 20:53:53 +07:00
Reynaldi Chernando 64a7c7b7c4 Add mcp docs (#3245) 2026-06-10 20:31:31 +07:00
wwb1942 2dd2887ff7 ci: add docs build workflow (#3235)
* ci: add docs build workflow

Add CI workflow that runs npm ci and npm run build in src/docs

whenever files under src/docs change in a pull request.

Closes #3225

* ci: also trigger docs build when workflow file changes

* ci: address docs build workflow review
2026-06-10 10:53:40 +07:00
ProgrammerIn-wonderland 4bbe8a2789 add protections for PUT-1094 (#3243) 2026-06-09 22:29:57 -04:00
Dilan Melvin T 3e9ceb673b fix: coerce Mistral image_url parts from object to string (#3177)
* fix: coerce Mistral image_url parts from object to string

Mistral's API expects image_url content parts to be a plain string URL,
not the OpenAI-style { url: string } object that process_input_messages
produces. Without this coercion, sending an image to any Mistral model
that supports vision (mistral-small, mistral-medium, mistral-large,
ministral-*) results in a request error from the Mistral SDK.

Add #coerceImageUrls() as a private method on MistralAIProvider that
maps { type: 'image_url', image_url: { url } } -> { type: 'image_url',
image_url: url } for every content part in every message. Messages with
plain string content are left untouched, as are parts whose image_url is
already a string.

Add four unit tests covering: object-to-string coercion, already-flat
strings, plain string message content, and mixed text+image content.

* style: use plain ASCII dashes in comment section divider
2026-06-09 20:12:44 -04:00
Daniel Salazar 9c4d1ef535 fix: misc bugs + new middleware for before/after routes (#3242) 2026-06-09 17:05:56 -07:00
ProgrammerIn-wonderland bf57518262 MCP: assert worker_exec ends with puter.work (#3241) 2026-06-09 17:43:34 -04:00
Reynaldi Chernando d48fb11de6 Add claude fable 5 (#3238) 2026-06-09 14:05:31 -04:00
ProgrammerIn-wonderland 7ed87f760b make max tokens less lenient (#3240) 2026-06-09 14:03:13 -04:00
Reynaldi Chernando ee37be16f0 Add cli deployment docs (#3239) 2026-06-10 00:40:19 +07:00
Reynaldi Chernando a8cfaeefbd Add subdomain and workers github actions deployment (#3226)
* Add subdomain and workers github actions deployment

* minor adjust
2026-06-09 23:35:34 +07:00
Badri467 58cb8ee048 skip github actions backend for docs only changes (#3236) 2026-06-09 23:28:48 +07:00
Daniel Salazar a72fbd1d47 fix: inform acl of full access flag (#3237) 2026-06-09 08:04:36 -07:00
Daniel Salazar f6408db74e fix: access tokens with full accesses allowed through with new middleware gate (#3234) 2026-06-08 18:47:07 -07:00
ProgrammerIn-wonderland df20993d8b set azure openai as the default (#3233) 2026-06-08 20:33:41 -04:00
ProgrammerIn-wonderland 708fe1bc4e azure ai provider (#3232) 2026-06-08 18:31:49 -04:00
Daniel Salazar e782fa832e fix: kv costs (#3231) 2026-06-08 13:32:36 -07:00
ProgrammerIn-wonderland c947c2c989 fix mcp server cloudfront cache bug (#3230) 2026-06-08 15:42:49 -04:00
Daniel Salazar 3de4613b7d Revert "feat (PUT-1091): have gui tokens labeled and be different than reques…" (#3229)
This reverts commit 1335b46404.
2026-06-08 11:15:24 -07:00
Daniel Salazar def7290c48 feat: emit user.deleted event on user deletion (#3228) 2026-06-08 10:30:24 -07:00
Daniel Salazar 1335b46404 feat (PUT-1091): have gui tokens labeled and be different than requestor ones (#3222)
* feat (PUT-1091): have gui tokens labeled and be different than creator ones

* fix: errors with auth token
2026-06-08 09:03:02 -07:00
Reynaldi Chernando 0e15ac98a9 Update cli readme and set version (#3227) 2026-06-08 22:23:32 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI f127b2509d Add docs fixes for deployment and workers pages (#3223)
* Add docs fixes for deployment and workers pages

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* handle router edge case

* explicit info about wildcard requiring name

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-08 17:43:00 +07:00
105 changed files with 6626 additions and 283 deletions

No files matched your search

+2
View File
@@ -3,6 +3,8 @@ name: Backend Tests
on:
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- 'src/docs/**'
permissions:
contents: read
+27
View File
@@ -0,0 +1,27 @@
name: Docs Build
on:
pull_request:
paths:
- 'src/docs/**'
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run build
working-directory: src/docs
+4
View File
@@ -260,6 +260,10 @@
// ─ Chat / completion ─
"claude": { "apiKey": "" },
"openai-completion": { "apiKey": "" },
"azure-openai": {
"apiKey": "",
"apiURL": ""
},
"gemini": { "apiKey": "" },
"groq": { "apiKey": "" },
"deepseek": { "apiKey": "" },
+1 -1
View File
@@ -71,6 +71,6 @@ export const handleInstalledApps = async (
extension.get(
'/installedApps',
{ subdomain: 'api', requireUserActor: true },
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
handleInstalledApps,
);
+8 -16
View File
@@ -15207,7 +15207,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16242,7 +16244,6 @@
},
"node_modules/undici": {
"version": "7.25.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -17619,6 +17620,7 @@
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"uglify-js": "^3.17.4",
"undici": "^7.25.0",
"uuid": "^14.0.0",
"validator": "^13.15.35"
},
@@ -17696,11 +17698,11 @@
},
"src/cli": {
"name": "@heyputer/cli",
"version": "0.1.0",
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^0.7.0",
"@heyputer/puter.js": "latest",
"@heyputer/puter.js": "^2.5.1",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"conf": "^13.0.0"
@@ -17712,16 +17714,6 @@
"node": ">=18"
}
},
"src/cli/node_modules/@heyputer/puter.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@heyputer/puter.js/-/puter.js-2.5.0.tgz",
"integrity": "sha512-FHtlWa9fAeQn7tYLaFKHTFHlviusGviE5tWGQl3N+huuU5C2/friJ7BrTwQg0egfOM7LF6cO56hjJvXaHVeL6w==",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0"
}
},
"src/cli/node_modules/chalk": {
"version": "5.6.2",
"license": "MIT",
@@ -18607,8 +18599,8 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.5.1",
"license": "AGPL-3.0-only",
"version": "2.5.3",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0"
@@ -85,6 +85,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[50, ['0054_sessions_workers.sql']],
[50, ['0055_username_nocase_unique.sql']],
[51, ['0056_sessions_kind_worker.sql']],
[52, ['0057_add_user_reputation.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,21 @@
-- 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/>.
-- Abuse v2 reputation score (0–100+) recorded at signup. Mirrors the
-- `reputation smallint DEFAULT 100` column already present in the mysql and
-- postgres baseline migrations.
ALTER TABLE `user` ADD COLUMN `reputation` SMALLINT DEFAULT 100;
+104 -1
View File
@@ -14,6 +14,10 @@
* the SocketService fans out to user-scoped channels.
*/
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { Actor } from '../../core';
import { FSEntry } from '../../stores/fs/FSEntry';
@@ -126,7 +130,7 @@ export type EventMap = {
req?: unknown;
data?: unknown;
abuse?: unknown;
custom?: unknown;
trail?: Array<string>;
[key: string]: unknown;
};
'puter.signup.success': {
@@ -160,6 +164,15 @@ export type EventMap = {
new_username: string;
};
'user.email-changed': { user_id: number; new_email: string };
// Fired after an account is torn down (self-serve, admin, or temp-user
// logout cleanup). Listeners purge external state tied to the account —
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
// The row is already gone by emit time, so identifiers ride the payload.
'user.delete': {
user_id: number;
user_uuid?: string;
stripe_customer_id?: string | null;
};
// ---- Filesystem ----
'fs.copy.node': {
@@ -211,6 +224,8 @@ export type EventMap = {
entry: unknown;
host: string;
requestPath: string;
requestUrl?: string;
requestHash?: string;
mime: string;
};
@@ -257,6 +272,94 @@ export type EventMap = {
// SocketService re-emits each fanout-eligible event under
// `sent-to-user.<wireName>` so per-user channels can subscribe by wire name.
[K in `sent-to-user.${string}`]: { user_id: number; response: unknown };
} & {
// Generic per-driver-method lifecycle, emitted by DriverController for
// EVERY driver call under a key scoped to the interface + method:
// `driver.<iface>.<method>.before|after|error|reject`. Wildcard
// subscribers can listen to `driver.*` for everything, `driver.<iface>.*`
// for one interface, or the exact key for one method. The `.before` phase
// is emitted via `emitAndWait`, so a listener may set `allow = false`
// (with an optional `rejectReason`) to veto the call before it runs — a
// vetoed call emits `.reject` instead of running.
[K in `driver.${string}`]: DriverMethodLifecycleEvent;
} & {
// Generic per-route-endpoint lifecycle, emitted by the route materializer
// for EVERY non-middleware route under a key scoped to the HTTP method +
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
// wildcard + veto semantics as the driver lifecycle above.
[K in `route.${string}`]: RouteLifecycleEvent;
};
/**
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
* listener vetoes the call (sets `allow = false`); the call never runs and no
* `after`/`error` follows.
*/
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
/**
* Payload for `driver.<iface>.<method>.<phase>` events.
*
* One shape across all phases; read `phase` (or the key suffix) to
* branch. `allow`/`rejectReason` are only meaningful on the `before` phase
* (emitted via `emitAndWait`).
*/
export type DriverMethodLifecycleEvent = {
phase: LifecyclePhase;
iface: string;
method: string;
/** Resolved concrete driver name. */
driver: string;
/** Full actor object, if the request is authenticated. */
actor?: Actor;
/** Stable actor id (see `actorUid`), if the request is authenticated. */
actorUid?: string;
/** Call arguments. Present on every phase. */
args?: unknown;
/** Return value. Present on `after`. */
result?: unknown;
/** Thrown error. Present on `error`. */
error?: unknown;
/** Wall-clock duration of the invocation. Present on `after`/`error`. */
durationMs?: number;
/** Veto channel for `before`: set `false` to block the call. */
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
};
/**
* Payload for `route.<method>.<path>.<phase>` events. Mirrors
* {@link DriverMethodLifecycleEvent} for HTTP endpoints.
*/
export type RouteLifecycleEvent = {
phase: LifecyclePhase;
/** HTTP method, lowercased (`get`, `post`, ...). */
method: string;
/** Full route path including the controller prefix. */
path: string;
/**
* The live express request/response for this call. Present on every phase.
* On `before` a listener can read the parsed body/headers or write its own
* response; on terminal phases they're useful for logging. These are real
* in-process objects — never serialize or forward them across nodes.
*/
req: ExpressRequest;
res: ExpressResponse;
/** Full actor object, if the request is authenticated. */
actor?: Actor;
/** Stable actor id (see `actorUid`), if the request is authenticated. */
actorUid?: string;
/** Response status code. Present on `after`/`error`. */
statusCode?: number;
/** Wall-clock duration from `before` to terminal phase. */
durationMs?: number;
/** Set when the request did not complete normally (abort / >=500). */
error?: unknown;
/** Veto channel for `before`: set `false` to block the request. */
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
};
export type EventKey = keyof EventMap & string;
@@ -56,6 +56,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const apps = await this.appDriver.select({
@@ -71,6 +72,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const name = req.query?.name;
@@ -185,6 +187,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const raw = req.params.name;
@@ -39,6 +39,7 @@ import { runWithContext } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { requireUserActorGate } from '../../core/http/middleware/gates.js';
import { PuterServer } from '../../server.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
// ── Test harness ────────────────────────────────────────────────────
@@ -73,6 +74,7 @@ type SignupValidateOverride = (data: {
let signupValidateOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
const heardUserDelete: Array<Record<string, unknown>> = [];
const installSharedListeners = () => {
eventClient.on('puter.signup.validate', (_k: unknown, data: unknown) => {
@@ -85,6 +87,9 @@ const installSharedListeners = () => {
eventClient.on('puter.signup.success', (_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
eventClient.on('user.delete', (_k: unknown, data: unknown) => {
heardUserDelete.push(data as Record<string, unknown>);
});
};
const withSignupValidateOverride = async <T>(
@@ -1283,6 +1288,78 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
expect(decoded.user_uid).toBe(actor.user.uuid);
});
// -- Full-API-access + labels --
it('mints a full-access token and stores a trimmed label on the session row', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: [FULL_API_ACCESS], label: ' My CLI ' },
{ actor },
),
res,
);
const decoded = server.services.token.verify(
'auth',
(res.body as { token: string }).token,
) as {
type: string;
token_uid: string;
session_uid: string;
full_access?: boolean;
};
expect(decoded.type).toBe('access-token');
// Full access is a signed claim, not a stored permission row.
expect(decoded.full_access).toBe(true);
const permRows = (await server.clients.db.read(
'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?',
[decoded.token_uid],
)) as Array<{ permission: string }>;
expect(permRows).toHaveLength(0);
const sessRows = (await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[decoded.session_uid],
)) as Array<{ label: string }>;
expect(sessRows[0]?.label).toBe('My CLI');
});
it('caps an over-long label at 64 characters', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq(
{ permissions: [FULL_API_ACCESS], label: 'x'.repeat(120) },
{ actor },
),
res,
);
const decoded = server.services.token.verify(
'auth',
(res.body as { token: string }).token,
) as { session_uid: string };
const sessRows = (await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[decoded.session_uid],
)) as Array<{ label: string }>;
expect(sessRows[0]?.label.length).toBe(64);
});
it('rejects a non-string label with 400', async () => {
await expect(
controller.handleCreateAccessToken(
makeReq(
{
permissions: [FULL_API_ACCESS],
label: 123 as unknown as string,
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-access-token: requires tokenOrUuid and returns ok:true on success', async () => {
// Mint, then revoke.
const created = makeRes();
@@ -1608,6 +1685,43 @@ describe('AuthController password recovery', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('set-pass-using-token: revokes all of the user’s interactive sessions', async () => {
// A password reset is the "someone may have my account" flow, so
// existing sessions must not survive it.
const { user } = await makeUserAndActor();
const recoveryToken = uuidv4();
await server.stores.user.update(user.id, {
pass_recovery_token: recoveryToken,
});
const jwt = server.services.token.sign(
'otp',
{
token: recoveryToken,
user_uid: user.uuid,
email: user.email,
purpose: 'pass-recovery',
},
{ expiresIn: '1h' },
);
const s1 = await server.services.auth.createSessionToken(user, {});
const s2 = await server.services.auth.createSessionToken(user, {});
const uuid1 = (s1.session as { uuid: string }).uuid;
const uuid2 = (s2.session as { uuid: string }).uuid;
// Both are live before the reset.
expect(await server.stores.session.getByUuid(uuid1)).not.toBeNull();
expect(await server.stores.session.getByUuid(uuid2)).not.toBeNull();
await controller.handleSetPassUsingToken(
makeReq({ token: jwt, password: 'a-brand-new-password' }),
makeRes(),
);
// Every interactive session is revoked (getByUuid gates on revoked_at).
expect(await server.stores.session.getByUuid(uuid1)).toBeNull();
expect(await server.stores.session.getByUuid(uuid2)).toBeNull();
});
});
// ── User-protected change-* (skipping middleware-driven setup) ─────
@@ -1654,6 +1768,34 @@ describe('AuthController user-protected mutations (validation paths)', () => {
).toBe(true);
});
it('change-password: revokes the user’s other web sessions but keeps the current one', async () => {
const { user, actor } = await makeUserAndActor();
// Two web sessions for this user; one is the session performing the
// change (the "current" one, identified via actor.session.uid).
const current = await server.services.auth.createSessionToken(user, {});
const other = await server.services.auth.createSessionToken(user, {});
const currentUuid = (current.session as { uuid: string }).uuid;
const otherUuid = (other.session as { uuid: string }).uuid;
const req = makeReq(
{ new_pass: 'a-fresh-password-123' },
{
actor: {
...actor,
session: { uid: currentUuid },
} as typeof actor,
},
);
(req as unknown as { userProtected: unknown }).userProtected = { user };
await controller.handleChangePassword(req, makeRes());
// The other session is revoked; the one that made the change survives.
expect(await server.stores.session.getByUuid(otherUuid)).toBeNull();
expect(
await server.stores.session.getByUuid(currentUuid),
).not.toBeNull();
});
it('change-username: 400 on missing/invalid/reserved/already-taken usernames', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -2616,6 +2758,35 @@ describe('AuthController.handleDeleteOwnUser', () => {
});
expect(after).toBeFalsy();
});
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
// captures it (it's how the marketplace extension cancels the sub).
try {
await server.clients.db.write(
'ALTER TABLE user ADD COLUMN stripe_customer_id TEXT',
[],
);
} catch {
/* already exists */
}
const { user, actor } = await makeUserAndActor();
await server.clients.db.write(
'UPDATE user SET stripe_customer_id = ? WHERE id = ?',
['cus_delete_test', user.id],
);
heardUserDelete.length = 0;
await controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes());
const evt = heardUserDelete.find((e) => e.user_id === user.id);
expect(evt).toMatchObject({
user_id: user.id,
user_uuid: user.uuid,
stripe_customer_id: 'cus_delete_test',
});
});
});
// ── Additional branch coverage ─────────────────────────────────────
@@ -3657,27 +3828,60 @@ describe('AuthController.handleMigrateToken', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
it('rejects when the Origin header is not in config.origin or the allowlist', async () => {
it('allows the exchange from an unlisted origin (apps live on arbitrary domains)', async () => {
// puter.js apps run on any third-party domain; the v1 bearer
// token is the credential, so the exchange itself is not
// origin-gated — only cookie issuance is (next test).
const { user } = await makeUserAndActor();
const v1 = mintV1Token({
type: 'access-token',
token_uid: uuidv4(),
user_uid: user.uuid,
});
await expect(
controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://not-allowed.example',
authorization: `Bearer ${v1}`,
},
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
),
makeRes(),
},
),
).rejects.toMatchObject({ statusCode: 403 });
res,
);
expect((res.body as { kind: string }).kind).toBe('access_token');
expect((res.body as { token: string }).token).toBeTruthy();
});
it('does NOT set the puter_token_v2 cookie for an app token from an untrusted origin', async () => {
// Cookie planting on the GUI origin is what the allowlist
// prevents: an attacker page may exchange a token it already
// holds, but must not be able to set a session cookie.
const { user } = await makeUserAndActor();
const appUid = `app-${uuidv4()}`;
const v1 = mintV1Token({
type: 'app-under-user',
user_uid: user.uuid,
app_uid: appUid,
});
const res = makeRes();
await controller.handleMigrateToken(
makeReq(
{},
{
headers: {
origin: 'https://some-app.example',
authorization: `Bearer ${v1}`,
},
},
),
res,
);
expect((res.body as { kind: string }).kind).toBe('app');
expect((res.body as { token: string }).token).toBeTruthy();
expect(res.cookies.puter_token_v2).toBeUndefined();
});
it('normalizes trailing slash on the request Origin (B4)', async () => {
+131 -27
View File
@@ -99,7 +99,16 @@ export class AuthController extends PuterController {
@Post('/login', {
captcha: true,
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
// Two limits: per-fingerprint keeps users behind a shared IP
// (offices, campuses) from throttling each other, while the
// coarser per-IP backstop stops an attacker from minting fresh
// fingerprint buckets by rotating client-controlled headers
// (User-Agent etc.). Same pattern on the other unauthenticated
// credential endpoints below.
rateLimit: [
{ scope: 'login', limit: 10, window: 15 * 60_000 },
{ scope: 'login-ip', limit: 50, window: 15 * 60_000, key: 'ip' },
],
})
async handleLogin(req: Request, res: Response): Promise<void> {
const { username, email, password } = req.body;
@@ -206,11 +215,15 @@ export class AuthController extends PuterController {
@Post('/login/otp', {
captcha: true,
rateLimit: {
scope: 'login-otp',
limit: 15,
window: 30 * 60_000,
},
rateLimit: [
{ scope: 'login-otp', limit: 15, window: 30 * 60_000 },
{
scope: 'login-otp-ip',
limit: 60,
window: 30 * 60_000,
key: 'ip',
},
],
})
async handleLoginOtp(req: Request, res: Response): Promise<void> {
const { token, code } = req.body;
@@ -266,11 +279,15 @@ export class AuthController extends PuterController {
@Post('/login/recovery-code', {
captcha: true,
rateLimit: {
scope: 'login-recovery',
limit: 10,
window: 60 * 60_000,
},
rateLimit: [
{ scope: 'login-recovery', limit: 10, window: 60 * 60_000 },
{
scope: 'login-recovery-ip',
limit: 40,
window: 60 * 60_000,
key: 'ip',
},
],
})
async handleLoginRecoveryCode(req: Request, res: Response): Promise<void> {
const { token, code } = req.body;
@@ -339,7 +356,10 @@ export class AuthController extends PuterController {
@Post('/signup', {
captcha: true,
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
rateLimit: [
{ scope: 'signup', limit: 10, window: 15 * 60_000 },
{ scope: 'signup-ip', limit: 50, window: 15 * 60_000, key: 'ip' },
],
})
async handleSignup(req: Request, res: Response): Promise<void> {
const body = req.body ?? {};
@@ -537,11 +557,14 @@ export class AuthController extends PuterController {
message: null,
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
// Populated by the abuse extension's v2 harness; persisted to the
// user row below so the signup-time reputation is referable later.
reputation: null as number | null,
};
try {
await this.clients.event?.emitAndWait(
'puter.signup.validate' as never,
validateEvent as never,
'puter.signup.validate',
validateEvent,
{},
);
} catch (e) {
@@ -602,6 +625,11 @@ export class AuthController extends PuterController {
// stays hardcoded here.
requires_email_confirmation: 1,
last_activity_ts: signupSqlTs,
// Record the v2 reputation from this claim (skip if unset so we
// don't clobber an existing score with a placeholder).
...(validateEvent.reputation != null
? { reputation: validateEvent.reputation }
: {}),
});
// Move from temp group to regular user group
@@ -658,6 +686,7 @@ export class AuthController extends PuterController {
signup_server: (this.config as { serverId?: string }).serverId,
referrer: req.body.referrer ?? null,
last_activity_ts: signupSqlTs,
reputation: validateEvent.reputation,
} as never);
// Add to default group
@@ -1129,6 +1158,12 @@ export class AuthController extends PuterController {
}
await this.stores.user.invalidateById(user.id);
// A password reset is the "I think someone else has access" flow —
// evict every interactive session so a hijacked one doesn't survive.
await this.services.auth.revokeInteractiveSessionsForUserId(
user.id as number,
);
res.send('Password successfully updated.');
}
@@ -1165,6 +1200,10 @@ export class AuthController extends PuterController {
change_email_confirm_token: null,
});
// Sign out every other web session (cascading to their derived
// rows); only the session that changed the password survives.
await this.services.auth.revokeAllSessions(req.actor!);
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
@@ -2140,12 +2179,15 @@ export class AuthController extends PuterController {
// -- Access tokens -----------------------------------------------
async handleMigrateToken(req: Request, res: Response): Promise<void> {
// 1. Origin lock. Reject anything that isn't same-origin to
// `config.origin` or in the explicit per-deployment allowlist.
// No Origin header → reject (this endpoint is browser-only by
// design; server-side callers should re-auth properly).
// 1. Browser-only gate. No Origin header → reject (server-side
// callers should re-auth properly). The exchange itself is open
// to any browser origin: puter.js apps live on arbitrary
// third-party domains, the v1 bearer token is the credential,
// and cookies are never read here — so a hostile page learns
// nothing it doesn't already hold. Origin *trust* only decides
// whether the `puter_token_v2` companion cookie is set below.
const reqOrigin = req.headers.origin;
if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) {
if (!reqOrigin) {
throw new HttpError(403, 'Origin not allowed', {
legacyCode: 'forbidden',
});
@@ -2175,12 +2217,19 @@ export class AuthController extends PuterController {
: undefined,
});
// `puter_token_v2` is the cookie companion to v2 app tokens —
// the app runs in-browser (we already gated on Origin above) so
// the GUI's cookie-only middleware can authenticate subsequent
// calls from the same iframe without the client having to
// forward Authorization headers. Access tokens are programmatic
// (no browser cookie surface) so we deliberately skip them here.
if (result.kind === 'app') {
// it lets the GUI's cookie-only middleware authenticate
// subsequent same-origin calls without the client forwarding
// Authorization headers. Issuing it is gated on the trusted
// origin allowlist: an arbitrary attacker page must not be able
// to plant a session cookie on the GUI origin (login CSRF).
// Untrusted origins still get the token in the JSON body — the
// cookie is only consumed same-origin (see authProbe) so they
// lose nothing. Access tokens are programmatic (no browser
// cookie surface) so we deliberately skip them here.
if (
result.kind === 'app' &&
this.#isMigrateTokenOriginAllowed(reqOrigin)
) {
res.cookie('puter_token_v2', result.token, {
...sessionCookieFlags(this.config),
httpOnly: true,
@@ -2212,13 +2261,25 @@ export class AuthController extends PuterController {
requireAuth: true,
})
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
const { permissions, expiresIn } = req.body;
const { permissions, expiresIn, label } = req.body;
if (!Array.isArray(permissions) || permissions.length === 0) {
throw new HttpError(400, 'Missing or empty `permissions` array', {
legacyCode: 'bad_request',
});
}
// Optional user-facing name for the manage-sessions UI. Trim and clamp
// to the same 64-char limit the rename endpoint enforces.
let normalizedLabel: string | null = null;
if (label !== undefined && label !== null) {
if (typeof label !== 'string') {
throw new HttpError(400, '`label` must be a string', {
legacyCode: 'bad_request',
});
}
normalizedLabel = label.trim().slice(0, 64) || null;
}
// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
const normalized = permissions.map((spec) => {
if (typeof spec === 'string') return [spec];
@@ -2233,7 +2294,10 @@ export class AuthController extends PuterController {
const token = await this.services.auth.createAccessToken(
req.actor!,
normalized as never,
expiresIn ? { expiresIn } : {},
{
...(expiresIn ? { expiresIn } : {}),
...(normalizedLabel ? { label: normalizedLabel } : {}),
},
);
res.json({ token });
}
@@ -2470,6 +2534,9 @@ export class AuthController extends PuterController {
});
await this.stores.group.addUsers(uid, users);
// New members inherit the group's permissions immediately, not
// after the permission-cache TTL.
await this.services.permission.bumpPermissionCacheForUsernames(users);
res.json({});
}
@@ -2499,6 +2566,9 @@ export class AuthController extends PuterController {
});
await this.stores.group.removeUsers(uid, users);
// Removed members must lose the group's permissions immediately,
// not after the permission-cache TTL.
await this.services.permission.bumpPermissionCacheForUsernames(users);
res.json({});
}
@@ -2805,6 +2875,22 @@ export class AuthController extends PuterController {
// -- Private helpers ----------------------------------------------
async #cascadeDeleteUser(userId: number): Promise<void> {
// Capture the identifiers downstream teardown needs before the row is
// gone — the marketplace extension cancels the user's Stripe
// subscriptions off `user.delete`, keyed by uuid / customer id.
let userUuid: string | undefined;
let stripeCustomerId: string | null = null;
try {
const rows = (await this.clients.db.read(
'SELECT `uuid`, `stripe_customer_id` FROM `user` WHERE `id` = ?',
[userId],
)) as Array<{ uuid?: string; stripe_customer_id?: string | null }>;
userUuid = rows[0]?.uuid;
stripeCustomerId = rows[0]?.stripe_customer_id ?? null;
} catch (e) {
console.warn('[cascade-delete-user] identifier lookup failed:', e);
}
try {
await this.services.fs.removeAllForUser(userId);
} catch (e) {
@@ -2822,6 +2908,24 @@ export class AuthController extends PuterController {
userId,
]);
await this.stores.user.invalidateById(userId);
// Fire-and-forget: let listeners purge external state tied to the
// account (Stripe subscriptions are cancelled immediately, without
// proration). Emitted after the row delete — listeners key off the
// payload, not the DB row.
try {
this.clients.event?.emit(
'user.delete',
{
user_id: userId,
user_uuid: userUuid,
stripe_customer_id: stripeCustomerId,
},
{},
);
} catch {
// ignore — event emission shouldn't block deletion
}
}
async #generateRandomUsername(): Promise<string> {
@@ -51,6 +51,7 @@ export class DesktopController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const { url, color, fit } = req.body ?? {};
@@ -105,6 +106,7 @@ export class DesktopController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const { items } = req.body ?? {};
@@ -130,6 +132,7 @@ export class DesktopController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const { item_uid, item_path, layout } = req.body ?? {};
@@ -156,6 +159,7 @@ export class DesktopController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const { item_uid, item_path, sort_by, sort_order } =
@@ -21,7 +21,8 @@ import { Readable } from 'node:stream';
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { actorUid, type Actor } from '../../core/actor.js';
import type { DriverMethodLifecycleEvent } from '../../clients/event/types.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -460,6 +461,129 @@ describe('DriverController.#handleCall (via captured router)', () => {
});
});
describe('DriverController driver-method lifecycle events', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
const grantKvPerm = async (actor: Actor) => {
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-kvstore:ii:puter-kvstore',
{
permission: 'service:puter-kvstore:ii:puter-kvstore',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
};
it('emits before then after around a successful driver method', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
// Correlate on the actor: each test creates a fresh user, so this
// isolates our call from any other traffic on the shared event bus.
const who = actorUid(actor);
const before: DriverMethodLifecycleEvent[] = [];
const after: DriverMethodLifecycleEvent[] = [];
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if (data.actorUid === who) before.push(data);
},
);
server.clients.event.on(
'driver.puter-kvstore.set.after',
(_k, data) => {
if (data.actorUid === who) after.push(data);
},
);
const key = `lc-${uuidv4()}`;
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
);
expect(before).toHaveLength(1);
expect(before[0]).toMatchObject({
phase: 'before',
iface: 'puter-kvstore',
method: 'set',
actor,
actorUid: who,
});
expect(after).toHaveLength(1);
expect(after[0].phase).toBe('after');
expect(after[0].args).toMatchObject({ key, value: 'v' });
expect(typeof after[0].durationMs).toBe('number');
});
it('emits reject and answers 403 when a before listener vetoes', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
// Scope the veto to a sentinel key so it doesn't affect other calls
// sharing this server's event bus.
const key = `veto-${uuidv4()}`;
const reject: DriverMethodLifecycleEvent[] = [];
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if ((data.args as { key?: string })?.key === key) {
data.allow = false;
data.rejectReason = 'blocked in test';
}
},
);
server.clients.event.on(
'driver.puter-kvstore.set.reject',
(_k, data) => {
if (data.rejectReason === 'blocked in test') reject.push(data);
},
);
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(reject).toHaveLength(1);
expect(reject[0]).toMatchObject({
phase: 'reject',
method: 'set',
args: { key, value: 'v' },
rejectReason: 'blocked in test',
});
});
});
describe('DriverController.#handleListInterfaces', () => {
let routes: Captured;
beforeAll(() => {
@@ -18,6 +18,7 @@
*/
import type { Request, Response } from 'express';
import { actorUid } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { Controller } from '../../core/http/decorators.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
@@ -34,6 +35,7 @@ import {
resolveDriverMethodRateLimit,
} from '../../drivers/meta.js';
import type { PermissionService } from '../../services/permission/PermissionService.js';
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { PuterController } from '../types.js';
@@ -221,7 +223,16 @@ export class DriverController extends PuterController {
| PermissionService
| undefined;
if (permService) {
const permKey = `service:${resolvedDriverName}:ii:${ifaceName}`;
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
// crafted name shift permission-segment boundaries and match
// a broader/narrower parent than intended in the scan logic.
const permKey = PermissionUtil.join(
'service',
String(resolvedDriverName),
'ii',
ifaceName,
);
const hasPermission = await permService.check(
req.actor,
permKey,
@@ -324,14 +335,91 @@ export class DriverController extends PuterController {
// a stale value from a prior call.
Context.set('driverName', requestedDriver);
// Per-method lifecycle events, scoped to `driver.<iface>.<method>`.
// Subscribers can listen on `driver.*`, `driver.<iface>.*`, or the
// exact key. `before` is emitted via `emitAndWait` so a listener may
// veto the call by setting `allow = false` (emits `reject`, throws
// 403); otherwise `after`/`error` carry the result/error + duration.
const actor = req.actor ? actorUid(req.actor) : undefined;
const resolved = String(resolvedDriverName);
const beforeEvent = {
phase: 'before' as const,
iface: ifaceName,
method,
driver: resolved,
actor: req.actor,
actorUid: actor,
args,
allow: true as boolean,
rejectReason: undefined as string | undefined,
};
await this.clients.event?.emitAndWait(
`driver.${ifaceName}.${method}.before`,
beforeEvent,
{},
);
if (beforeEvent.allow === false) {
this.clients.event?.emit(
`driver.${ifaceName}.${method}.reject`,
{
phase: 'reject',
iface: ifaceName,
method,
driver: resolved,
actor: req.actor,
actorUid: actor,
args,
rejectReason: beforeEvent.rejectReason,
},
{},
);
throw new HttpError(
403,
beforeEvent.rejectReason ??
`Blocked by policy: ${ifaceName}:${method}`,
{ legacyCode: 'forbidden' },
);
}
// Drivers read actor/context via the Context API — no drilled args.
const startedAt = Date.now();
let result;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result = await (fn as (...x: unknown[]) => any).call(driver, args);
} catch (e) {
this.clients.event?.emit(
`driver.${ifaceName}.${method}.error`,
{
phase: 'error',
iface: ifaceName,
method,
driver: resolved,
actor: req.actor,
actorUid: actor,
args,
error: e,
durationMs: Date.now() - startedAt,
},
{},
);
throw translateProviderError(e);
}
this.clients.event?.emit(
`driver.${ifaceName}.${method}.after`,
{
phase: 'after',
iface: ifaceName,
method,
driver: resolved,
actor: req.actor,
actorUid: actor,
args,
result,
durationMs: Date.now() - startedAt,
},
{},
);
if (isDriverStreamResult(result)) {
res.setHeader('Content-Type', result.content_type);
@@ -434,6 +434,65 @@ describe('FSController.completeBatchWrites', () => {
expect(finalized?.wasOverwrite).toBe(true);
});
// Regression: a signed (direct-to-S3) upload could declare a tiny size
// and PUT far more — the presigned URL doesn't bound the body. The
// completion path must reconcile the recorded size against the object's
// true size, or storage accounting is permanently understated and the
// quota is bypassable.
it('reconciles the recorded size to the real uploaded bytes (ignores under-declared size)', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const target = `/${username}/Documents/under-declared.bin`;
// Declare 1 byte.
const start = makeRes();
await withActor(actor, () =>
controller.startBatchWrites(
makeReq<SignedWriteRequest[]>({
body: [{ fileMetadata: { path: target, size: 1 } }],
actor,
}),
start.res,
),
);
const [started] = start.captured.body as SignedWriteResponse[];
// Actually upload 4096 bytes to the session's object key (simulating
// a client that PUTs more than it declared via the signed URL).
const realBytes = Buffer.alloc(4096, 0x41);
await server.stores.s3Object.uploadFromServer(
{
bucket: started!.bucket,
objectKey: started!.objectKey,
contentType: 'application/octet-stream',
body: realBytes,
contentLength: realBytes.byteLength,
},
started!.bucketRegion,
);
const complete = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: [{ uploadId: started!.sessionId }],
actor,
}),
complete.res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(target);
expect(entry).not.toBeNull();
// Recorded size is the true 4096 bytes, not the declared 1.
expect(entry?.size).toBe(4096);
// And the user's accounted usage reflects the real bytes.
const allowance =
await server.stores.fsEntry.getUserStorageAllowance(userId);
expect(allowance.curr).toBeGreaterThanOrEqual(4096);
});
it('emits updated events with GUI metadata when overwriting via batch completion', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
+7 -1
View File
@@ -37,6 +37,7 @@ import {
runWithConcurrencyLimit,
runWithConcurrencyLimitSettled,
} from '../../util/concurrency.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { PuterController } from '../types.js';
import { FS_COSTS } from './costs.js';
import type {
@@ -964,8 +965,13 @@ export class FSController extends PuterController {
range,
});
if (download.contentType)
if (download.contentType) {
res.setHeader('Content-Type', download.contentType);
// Stored type is uploader-controlled and served inline on the
// api origin — sandbox active-document types (HTML/SVG/XML) so
// embedded scripts can't run here. Inert types are untouched.
applyInlineContentSecurity(res, download.contentType);
}
if (download.contentLength !== null)
res.setHeader('Content-Length', String(download.contentLength));
if (download.contentRange)
@@ -29,6 +29,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { ACLService } from '../../services/acl/ACLService.js';
import type { SignedFile } from '../../util/fileSigning.js';
import { verifySignature } from '../../util/fileSigning.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { PuterController } from '../types.js';
import { FS_COSTS } from './costs.js';
import {
@@ -146,6 +147,11 @@ export class LegacyFSController extends PuterController {
{
subdomain: ['api', ''],
requireUserActor: true,
// The user's own credential may download their files; apps and
// scoped tokens stay blocked. antiCsrf still protects the
// cookie-authed GUI path — a bearer-token PAT is exempt below
// (CSRF can't forge a header-credentialed request).
allowFullAccessToken: true,
requireVerified: true,
antiCsrf: true,
},
@@ -1352,8 +1358,15 @@ export class LegacyFSController extends PuterController {
query.download === '1' ||
query.download === true;
if (download.contentType)
if (download.contentType) {
res.setHeader('Content-Type', download.contentType);
// Uploader-controlled type served inline on the file origin —
// sandbox active-document types so an uploaded HTML/SVG can't
// execute scripts here. Mirrors FSController /fs/read.
if (!wantsAttachment) {
applyInlineContentSecurity(res, download.contentType);
}
}
if (download.contentLength !== null)
res.setHeader('Content-Length', String(download.contentLength));
if (download.contentRange)
@@ -44,6 +44,7 @@ export class HostingController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
requireVerified: true,
},
async (req, res) => {
@@ -38,7 +38,11 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification.
* Sets `acknowledged` timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
@Post('/mark-ack', {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
})
async markAck(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
@@ -77,7 +81,11 @@ export class NotificationController extends PuterController {
* POST /notif/mark-read — user saw a notification.
* Sets `shown` timestamp; pushes ack event to sockets.
*/
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
@Post('/mark-read', {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
})
async markRead(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
+21 -1
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Request, Response } from 'express';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
@@ -24,6 +25,21 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { PEER_COSTS } from './costs.js';
/**
* Constant-time secret comparison for the internal-auth header. HMAC both
* sides under a random per-process key to a fixed 32-byte digest first:
* this avoids leaking length via an early-return and sidesteps
* `timingSafeEqual`'s equal-length requirement for arbitrary-length
* inputs. The key need not persist — it only has to be unknown to the
* attacker for the duration of the comparison.
*/
const COMPARE_KEY = randomBytes(32);
const secretsEqual = (a: string, b: string): boolean => {
const ha = createHmac('sha256', COMPARE_KEY).update(a).digest();
const hb = createHmac('sha256', COMPARE_KEY).update(b).digest();
return timingSafeEqual(ha, hb);
};
/**
* Encode a UUID (or `app-<uuid>` UID) as base64url with no padding.
* Strips an `app-` prefix and dashes, then reinterprets the hex bytes.
@@ -170,7 +186,11 @@ export class PeerController extends PuterController {
}
const expectedSecret = cfg.internal_auth_secret;
const header = req.headers['x-puter-internal-auth'];
if (!expectedSecret || header !== expectedSecret) {
if (
!expectedSecret ||
typeof header !== 'string' ||
!secretsEqual(header, expectedSecret)
) {
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
}
@@ -188,10 +188,11 @@ describe('PuterAIController.registerRoutes', () => {
]),
);
// The four upstream-proxy routes are user-only: app actors must
// call puter-chat-completion directly. The `requireUserActor`
// route option wires up the gate that enforces this, so each
// proxy route gets the assertion (not just chat/completions).
// The four upstream-proxy routes reject third-party apps (they must
// call puter-chat-completion directly), but admit the user's own
// full-access personal access token: `requireUserActor` keeps apps out
// and `allowFullAccessToken` opens the gate to a full-access PAT. Each
// proxy route carries both flags.
const userOnlyPaths = [
'/puterai/openai/v1/chat/completions',
'/puterai/openai/v1/completions',
@@ -203,6 +204,7 @@ describe('PuterAIController.registerRoutes', () => {
expect(route?.opts).toEqual({
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
});
}
const modelsRoute = calls.find(
@@ -52,6 +52,12 @@ export class PuterAIController extends PuterController {
const apiAuthOpts = {
subdomain: 'api',
requireUserActor: true,
// These are the user's own programmatic AI surface (OpenAI/Anthropic
// wire). `requireUserActor` here exists to keep third-party apps
// out, not to block the user's own credential — so admit full-access
// personal access tokens (CLI/MCP/scripts). Apps and scoped tokens
// stay blocked.
allowFullAccessToken: true,
} as RouteOptions;
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
@@ -77,6 +77,7 @@ export class SystemController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
scope: 'contact-us',
limit: 10,
+9
View File
@@ -33,6 +33,15 @@ export interface ActorAccessToken {
uid: string;
issuer: Actor;
authorized?: Actor | null;
/**
* Full-API-access ("personal access token") flag, set from the signed
* `full_access` JWT claim. Such a token may exercise everything its issuing
* user can do via the API and is admitted past `requireNonAccessTokenGate`
* — but is still rejected by `requireUserActor` / web-session gates, so it
* can never manage the account. Normal (scoped) access tokens leave this
* false and remain blocked from non-`allowAccessToken` routes.
*/
fullAccess?: boolean;
}
export interface Actor {
@@ -73,6 +73,19 @@ export const antiCsrf = {
export function requireAntiCsrf() {
return async (req, _res, next) => {
try {
// CSRF only applies to ambient credentials the browser attaches
// automatically (the session cookie). A full-access personal access
// token travels in the Authorization header — it can't be forged
// cross-origin — so it needs no anti-CSRF token, which is what makes
// routes like `/fs/down` reachable by a PAT. We deliberately scope
// this to full-access tokens (the only access tokens routed onto
// antiCsrf-protected endpoints): anything else — cookie-authed user
// actors, app actors, or a scoped token that somehow reaches here —
// still goes through the check below and fails closed.
if (req.actor?.accessToken?.fullAccess) {
return next();
}
const sessionId = req.actor?.user?.uuid;
if (!sessionId) {
return next(
@@ -118,6 +118,51 @@ describe('requireAntiCsrf middleware', () => {
expect(arg).toBeUndefined();
});
it('exempts a FULL-ACCESS access token (no anti_csrf token needed)', async () => {
// A full-access PAT is header-credentialed (bearer), so it can't be
// CSRF-forged — the middleware lets it through without a token. This is
// what makes antiCsrf routes like /fs/down reachable by a PAT.
const arg = await runMiddleware({
actor: {
user: { uuid: 'user-uuid-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'user-uuid-1' } },
fullAccess: true,
},
},
body: {},
});
expect(arg).toBeUndefined();
});
it('does NOT exempt a scoped (non-full-access) access token — fails closed', async () => {
// Scoped tokens are never deliberately routed onto antiCsrf endpoints;
// if one reaches here it must still present a token (which it can't get)
// rather than getting a free pass.
const arg = await runMiddleware({
actor: {
user: { uuid: 'user-uuid-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'user-uuid-1' } },
},
},
body: {},
});
expect(isHttpError(arg)).toBe(true);
expect(arg.statusCode).toBe(400);
});
it('still enforces the token for cookie-authed user actors (no accessToken)', async () => {
const arg = await runMiddleware({
actor: { user: { uuid: 'user-uuid-1' } },
body: {},
});
expect(isHttpError(arg)).toBe(true);
expect(arg.statusCode).toBe(400);
});
it('returns 401 unauthorized when no actor is attached', async () => {
const arg = await runMiddleware({ body: { anti_csrf: 'whatever' } });
expect(isHttpError(arg)).toBe(true);
@@ -26,6 +26,7 @@ import {
allowedAppIdsGate,
requireAuthGate,
requireEmailConfirmedGate,
requireNonAccessTokenGate,
requireUserActorGate,
requireVerifiedGate,
subdomainGate,
@@ -224,6 +225,110 @@ describe('requireUserActorGate', () => {
const got = runGate(requireUserActorGate(), {});
expectHttpError(got, 401, 'token_missing');
});
it('rejects a FULL-ACCESS access token too — account routes stay closed', () => {
// The account wall is actor-type based: even a full-access PAT (which
// the resource wall lets through) is rejected here, so it can never
// reach change-password/email/2FA/token-minting/etc.
const got = runGate(requireUserActorGate(), {
actor: {
user: { uuid: 'u-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1' } },
fullAccess: true,
},
},
});
expectHttpError(got, 403, 'forbidden');
});
// allowFullAccess opt-in: relaxes ONLY the access-token half, for
// user-resource/inference routes (e.g. the AI proxy).
it('admits a full-access PAT when allowFullAccess is set', () => {
const got = runGate(requireUserActorGate({ allowFullAccess: true }), {
actor: {
user: { uuid: 'u-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1' } },
fullAccess: true,
},
},
});
expect(got).toBeUndefined();
});
it('still rejects a SCOPED access token even when allowFullAccess is set', () => {
const got = runGate(requireUserActorGate({ allowFullAccess: true }), {
actor: {
user: { uuid: 'u-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1' } },
// no fullAccess
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('still rejects a third-party app even when allowFullAccess is set', () => {
const got = runGate(requireUserActorGate({ allowFullAccess: true }), {
actor: { user: { uuid: 'u-1' }, app: { uid: 'app-1' } },
});
expectHttpError(got, 403, 'forbidden');
});
});
// -- requireNonAccessTokenGate --
describe('requireNonAccessTokenGate', () => {
it('passes through for plain user actors', () => {
const got = runGate(requireNonAccessTokenGate(), {
actor: { user: { uuid: 'u-1' } },
});
expect(got).toBeUndefined();
});
it('passes through for app-under-user actors (only access tokens are gated)', () => {
const got = runGate(requireNonAccessTokenGate(), {
actor: { user: { uuid: 'u-1' }, app: { uid: 'app-1' } },
});
expect(got).toBeUndefined();
});
it('rejects a normal (scoped) access token with 403', () => {
const got = runGate(requireNonAccessTokenGate(), {
actor: {
user: { uuid: 'u-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1' } },
},
},
});
expectHttpError(got, 403, 'forbidden');
});
it('admits a FULL-ACCESS access token (the resource-wall carve-out)', () => {
const got = runGate(requireNonAccessTokenGate(), {
actor: {
user: { uuid: 'u-1' },
accessToken: {
uid: 'tok-1',
issuer: { user: { uuid: 'u-1' } },
fullAccess: true,
},
},
});
expect(got).toBeUndefined();
});
it("falls back to 401 if there's no actor at all (defensive)", () => {
const got = runGate(requireNonAccessTokenGate(), {});
expectHttpError(got, 401, 'token_missing');
});
});
// ── adminOnlyGate ───────────────────────────────────────────────────
+26 -3
View File
@@ -99,8 +99,18 @@ export const requireAuthGate = (): RequestHandler => {
* Reject app-under-user and access-token actors with 403. Use on endpoints
* that should only be exercised by a human session — settings changes,
* admin-style actions on the user's own account.
*
* `allowFullAccess` (set per-route via the `allowFullAccessToken` route option)
* relaxes ONLY the access-token half: a full-access ("personal access token")
* actor is admitted, because it represents the user's own full API reach.
* Third-party apps are ALWAYS rejected, and scoped access tokens are always
* rejected. This opt-in is for user-resource / inference endpoints (AI proxy,
* etc.) that use this gate purely to keep apps out — NEVER for account or
* security management, which must stay closed to every access token.
*/
export const requireUserActorGate = (): RequestHandler => {
export const requireUserActorGate = (
opts: { allowFullAccess?: boolean } = {},
): RequestHandler => {
return (req, _res, next) => {
const actor = req.actor;
// requireAuth runs first; this gate just narrows the actor type.
@@ -108,7 +118,14 @@ export const requireUserActorGate = (): RequestHandler => {
next(rejectAuth(req));
return;
}
if (actor.app || actor.accessToken) {
// Third-party apps are never allowed through this gate.
const appBlocked = !!actor.app;
// Access tokens are blocked unless the route opted in AND this is a
// full-access PAT (the user's own credential). Scoped tokens: blocked.
const tokenBlocked =
!!actor.accessToken &&
!(opts.allowFullAccess && actor.accessToken.fullAccess);
if (appBlocked || tokenBlocked) {
next(
new HttpError(
403,
@@ -129,7 +146,13 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
next(rejectAuth(req));
return;
}
if (actor.accessToken) {
// Full-access ("personal access token") access tokens are admitted here:
// they carry the user's full API reach by design. They remain blocked
// from account management because those routes also use
// `requireUserActorGate`, which rejects ALL access tokens. Normal
// (scoped) access tokens stay blocked from non-`allowAccessToken`
// routes.
if (actor.accessToken && !actor.accessToken.fullAccess) {
next(
new HttpError(
403,
@@ -140,6 +140,67 @@ describe('rateLimitGate — memory backend (default)', () => {
expect(isHttpError(re)).toBe(true);
});
// Stacked gates, mirroring the materializer's handling of a
// `rateLimit: [...]` array (each entry is its own gate; a rejection
// short-circuits the chain). This is the credential-endpoint pattern:
// per-fingerprint budget for fairness on shared IPs, per-IP backstop
// against header rotation.
const runStack = async (stack, req) => {
for (const opts of stack) {
const err = await runGate(opts, req);
if (err) return err;
}
return undefined;
};
it('stacked gates: per-IP backstop catches header rotation that escapes the fingerprint key', async () => {
const stack = [
{ limit: 3, window: 60_000, scope: 'stack-fp' },
{ limit: 6, window: 60_000, key: 'ip', scope: 'stack-ip' },
];
// Rotating the User-Agent mints a fresh fingerprint bucket every
// request, so the fingerprint gate admits all of these — the IP
// backstop must cap the total anyway.
for (let i = 0; i < 6; i++) {
const arg = await runStack(
stack,
makeReq({ headers: { 'user-agent': `rotated-ua-${i}` } }),
);
expect(arg).toBeUndefined();
}
const rejected = await runStack(
stack,
makeReq({ headers: { 'user-agent': 'rotated-ua-final' } }),
);
expect(isHttpError(rejected)).toBe(true);
expect(rejected.statusCode).toBe(429);
});
it('stacked gates: clients sharing an IP each get their own fingerprint budget under the IP cap', async () => {
const stack = [
{ limit: 2, window: 60_000, scope: 'stack2-fp' },
{ limit: 10, window: 60_000, key: 'ip', scope: 'stack2-ip' },
];
// Two clients (distinct UAs) behind one NAT IP: each gets the
// full per-fingerprint budget, and each is cut off by its own
// fingerprint bucket — not starved by the other's traffic.
for (const ua of ['alice-browser', 'bob-browser']) {
for (let i = 0; i < 2; i++) {
expect(
await runStack(
stack,
makeReq({ headers: { 'user-agent': ua } }),
),
).toBeUndefined();
}
const rejected = await runStack(
stack,
makeReq({ headers: { 'user-agent': ua } }),
);
expect(isHttpError(rejected)).toBe(true);
}
});
it("falls back to fingerprint when 'user' strategy is selected but no actor present", async () => {
// The fallback prevents anonymous traffic from sharing a single
// bucket (which would invite trivial DoS via global rate-limit).
@@ -0,0 +1,251 @@
/**
* 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 type { Request, Response } from 'express';
import { describe, expect, it } from 'vitest';
import { EventClient } from '../../clients/event/EventClient';
import type {
EventMap,
RouteLifecycleEvent,
} from '../../clients/event/types';
import type { IConfig } from '../../types';
import {
createRouteLifecycleMiddleware,
pathKeySegment,
routeEventKeyBase,
} from './routeLifecycle';
// -- Fakes --
//
// EventClient is a pure in-memory bus (no external deps), so we use the real
// one and register real listeners. The request/response are the boundary we
// fake: a minimal `res` that is an EventEmitter (for `finish`/`close`) and
// records status/body.
const makeEvents = () => new EventClient({} as IConfig);
class FakeRes extends EventEmitter {
statusCode = 200;
writableFinished = false;
headersSent = false;
body: unknown;
status(code: number) {
this.statusCode = code;
return this;
}
json(b: unknown) {
this.body = b;
return this;
}
}
const makeReq = (actorUuid?: string): Request =>
({
actor: actorUuid ? { user: { uuid: actorUuid } } : undefined,
}) as unknown as Request;
// Collect every emit on a given key into an array for assertions.
const record = (events: EventClient, key: keyof EventMap) => {
const seen: RouteLifecycleEvent[] = [];
events.on(key as never, (_k, data) =>
seen.push(data as RouteLifecycleEvent),
);
return seen;
};
const POST = 'post' as const;
const PATH = '/fs/completeBatchWrite';
const BASE = 'route.post.fs.completeBatchWrite';
describe('pathKeySegment', () => {
it('joins path parts with dots and drops slashes', () => {
expect(pathKeySegment('/fs/completeBatchWrite')).toBe(
'fs.completeBatchWrite',
);
});
it('strips the leading colon from path params', () => {
expect(pathKeySegment('/foo/:id')).toBe('foo.id');
});
it('maps the root path and non-string paths to placeholders', () => {
expect(pathKeySegment('/')).toBe('root');
expect(pathKeySegment(/^\/x/)).toBe('_');
});
});
describe('routeEventKeyBase', () => {
it('scopes the key to method + normalized path', () => {
expect(routeEventKeyBase(POST, PATH)).toBe(BASE);
});
});
describe('createRouteLifecycleMiddleware', () => {
it('emits before, then after on a clean finish', async () => {
const events = makeEvents();
const before = record(events, `${BASE}.before` as keyof EventMap);
const after = record(events, `${BASE}.after` as keyof EventMap);
const error = record(events, `${BASE}.error` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const req = makeReq('u-1');
const res = new FakeRes();
let nextCalled = false;
await mw(req, res as unknown as Response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(true);
expect(before).toHaveLength(1);
expect(before[0]).toMatchObject({
phase: 'before',
method: 'post',
path: PATH,
actor: { user: { uuid: 'u-1' } },
actorUid: 'user:u-1',
});
// The live req/res are exposed so listeners can read the body or
// respond themselves.
expect(before[0].req).toBe(req);
expect(before[0].res).toBe(res);
res.statusCode = 200;
res.writableFinished = true;
res.emit('finish');
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ phase: 'after', statusCode: 200 });
expect(typeof after[0].durationMs).toBe('number');
expect(error).toHaveLength(0);
});
it('emits error (not after) when the response is a 5xx', async () => {
const events = makeEvents();
const after = record(events, `${BASE}.after` as keyof EventMap);
const error = record(events, `${BASE}.error` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
await mw(makeReq(), res as unknown as Response, () => {});
res.statusCode = 500;
res.writableFinished = true;
res.emit('finish');
expect(after).toHaveLength(0);
expect(error).toHaveLength(1);
expect(error[0]).toMatchObject({ phase: 'error', statusCode: 500 });
});
it('emits error when the connection closes without finishing (abort)', async () => {
const events = makeEvents();
const error = record(events, `${BASE}.error` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
await mw(makeReq(), res as unknown as Response, () => {});
res.writableFinished = false;
res.emit('close');
expect(error).toHaveLength(1);
expect(error[0].phase).toBe('error');
expect(error[0].error).toBeInstanceOf(Error);
});
it('emits a terminal event only once across finish + close', async () => {
const events = makeEvents();
const after = record(events, `${BASE}.after` as keyof EventMap);
const error = record(events, `${BASE}.error` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
await mw(makeReq(), res as unknown as Response, () => {});
res.statusCode = 200;
res.writableFinished = true;
res.emit('finish');
res.emit('close');
expect(after).toHaveLength(1);
expect(error).toHaveLength(0);
});
it('vetoes the request when a before listener sets allow=false', async () => {
const events = makeEvents();
events.on(`${BASE}.before` as never, (_k, data) => {
(data as RouteLifecycleEvent).allow = false;
(data as RouteLifecycleEvent).rejectReason = 'quota exceeded';
});
const reject = record(events, `${BASE}.reject` as keyof EventMap);
const after = record(events, `${BASE}.after` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
let nextCalled = false;
await mw(makeReq('u-9'), res as unknown as Response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
expect(res.statusCode).toBe(403);
expect(res.body).toMatchObject({
error: { code: 'forbidden', message: 'quota exceeded' },
});
expect(reject).toHaveLength(1);
expect(reject[0]).toMatchObject({
phase: 'reject',
statusCode: 403,
rejectReason: 'quota exceeded',
});
// A vetoed request never runs, so no terminal after fires.
res.emit('finish');
expect(after).toHaveLength(0);
});
it('treats a listener-sent response as a terminal after (not a reject) with the real status', async () => {
const events = makeEvents();
events.on(`${BASE}.before` as never, (_k, data) => {
// Listener answers the request itself without vetoing.
const e = data as RouteLifecycleEvent;
(e.res as unknown as FakeRes).headersSent = true;
e.res.status(418).json({ teapot: true });
});
const reject = record(events, `${BASE}.reject` as keyof EventMap);
const after = record(events, `${BASE}.after` as keyof EventMap);
const mw = createRouteLifecycleMiddleware(events, POST, PATH);
const res = new FakeRes();
let nextCalled = false;
await mw(makeReq(), res as unknown as Response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
// Listener's own 418 stands; the middleware doesn't clobber it with 403.
expect(res.statusCode).toBe(418);
expect(res.body).toEqual({ teapot: true });
// Not a veto, so no reject — a terminal `after` keyed off the real 418.
expect(reject).toHaveLength(0);
expect(after).toHaveLength(1);
expect(after[0]).toMatchObject({ phase: 'after', statusCode: 418 });
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, RequestHandler } from 'express';
import { actorUid } from '../actor';
import type { EventClient } from '../../clients/event/EventClient';
import type { RouteMethod } from './types';
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
/**
* Normalize a route path into a dot-separated key segment so lifecycle events
* can be scoped per endpoint. Strips slashes and param markers so the key is
* stable (`/drivers/call` -> `drivers.call`, `/foo/:id` -> `foo.id`).
* Non-string paths (RegExp / arrays) collapse to a single `_` placeholder.
*/
export const pathKeySegment = (fullPath: RoutePathValue): string => {
if (typeof fullPath !== 'string') return '_';
const seg = fullPath
.split('/')
.filter(Boolean)
.map((p) => p.replace(/^:/, '').replace(/[^A-Za-z0-9_-]/g, '_'))
.join('.');
return seg || 'root';
};
/**
* Scoped event-key base for a route: `route.<method>.<normalized-path>`.
* Callers append `.before` / `.after` / `.error` / `.reject`.
*/
export const routeEventKeyBase = (
method: RouteMethod,
fullPath: RoutePathValue,
): `route.${string}` => `route.${method}.${pathKeySegment(fullPath)}`;
/**
* Build the per-endpoint lifecycle middleware for one route.
*
* On each request it: emits `before` (awaited, so a listener may veto by
* setting `allow = false`), times the handler, and emits a single terminal
* event when the response finishes or the connection closes — `after` on a
* clean response, `error` on an abort or a >=500, `reject` only when a
* listener vetoes (it then answers 403 and the handler never runs). A listener
* that writes its own response without vetoing terminates as `after`/`error`
* keyed off the real status, not `reject`.
*
* Subscribers can listen on `route.*`, `route.<method>.*`, or the exact key.
*/
export const createRouteLifecycleMiddleware = (
events: EventClient,
method: RouteMethod,
fullPath: RoutePathValue,
): RequestHandler => {
const pathLabel =
typeof fullPath === 'string' ? fullPath : String(fullPath);
const keyBase = routeEventKeyBase(method, fullPath);
return async (req: Request, res, next) => {
const actor = req.actor ? actorUid(req.actor) : undefined;
const startedAt = Date.now();
const base = {
method,
path: pathLabel,
req,
res,
actor: req.actor,
actorUid: actor,
};
const beforeEvent = {
phase: 'before' as const,
...base,
allow: true as boolean,
rejectReason: undefined as string | undefined,
};
await events.emitAndWait(`${keyBase}.before`, beforeEvent, {});
// Explicit veto: a listener set `allow = false`. Emit `reject` and
// answer 403 — unless the listener already wrote its own response, in
// which case we report that status rather than a misleading 403.
if (beforeEvent.allow === false) {
events.emit(
`${keyBase}.reject`,
{
phase: 'reject',
...base,
statusCode: res.headersSent ? res.statusCode : 403,
durationMs: Date.now() - startedAt,
rejectReason: beforeEvent.rejectReason,
},
{},
);
if (!res.headersSent) {
res.status(403).json({
error: {
code: 'forbidden',
message:
beforeEvent.rejectReason ?? 'Blocked by policy',
},
});
}
return;
}
// A listener answered the request itself without vetoing (it wrote a
// response in the `before` hook). That's a normal terminal outcome,
// not a reject — emit `after`/`error` keyed off the real status.
if (res.headersSent) {
const statusCode = res.statusCode;
const isError = statusCode >= 500;
events.emit(
`${keyBase}.${isError ? 'error' : 'after'}`,
{
phase: isError ? 'error' : 'after',
...base,
statusCode,
durationMs: Date.now() - startedAt,
},
{},
);
return;
}
let settled = false;
const settle = (aborted: boolean) => {
if (settled) return;
settled = true;
const statusCode = res.statusCode;
const isError = aborted || statusCode >= 500;
events.emit(
`${keyBase}.${isError ? 'error' : 'after'}`,
{
phase: isError ? 'error' : 'after',
...base,
statusCode,
durationMs: Date.now() - startedAt,
...(aborted ? { error: new Error('request aborted') } : {}),
},
{},
);
};
res.once('finish', () => settle(false));
res.once('close', () => settle(!res.writableFinished));
next();
};
};
+35 -14
View File
@@ -66,6 +66,22 @@ export type RoutePath = string | RegExp | Array<string | RegExp>;
* in the chain. Commented-out slots are reserved for the next chunks
* (body parsing, post-auth gates, timing).
*/
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
export interface RouteRateLimit {
limit: number;
window: number;
/**
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id`
* → cap). Same mechanic as `concurrent.bySubscription`: resolved
* via the metering service per request; falls back to the base
* `limit` when there's no actor / no metering / no match.
*/
bySubscription?: Record<string, number>;
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
scope?: string;
backend?: 'memory' | 'redis' | 'kv';
}
export interface RouteOptions {
/** Extra per-route middleware. Applied after built-in gates, before the handler. */
middleware?: RequestHandler[];
@@ -86,6 +102,17 @@ export interface RouteOptions {
/** Reject app/access-token actors. Implies `requireAuth`. */
requireUserActor?: boolean;
/**
* Only meaningful alongside `requireUserActor`. Relaxes the access-token
* half of that gate so a FULL-ACCESS personal access token (the user's own
* credential) is admitted — third-party apps and scoped tokens stay
* blocked. Use ONLY on user-resource / inference endpoints that gate with
* `requireUserActor` purely to keep apps out (e.g. the AI proxy). NEVER set
* on account/security/credential routes — those must stay closed to every
* access token. Default-deny: omitting this keeps PATs blocked.
*/
allowFullAccessToken?: boolean;
/** Allows access-tokens */
allowAccessToken?: boolean;
@@ -198,21 +225,15 @@ export interface RouteOptions {
* All registered backends stay co-resident at runtime, so different
* routes can pick whichever fits their access pattern. Omitting
* `backend` uses the server-wide default (`config.rate_limit.backend`).
*
* An array applies every limit independently (a request must pass all
* of them). Use this to pair a per-client budget with a coarser
* backstop on a different key — e.g. per-fingerprint for fairness on
* shared IPs, plus per-IP so rotating headers can't mint fresh
* fingerprint buckets indefinitely. Give each entry its own `scope`
* so the counters don't collide.
*/
rateLimit?: {
limit: number;
window: number;
/**
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id`
* → cap). Same mechanic as `concurrent.bySubscription`: resolved
* via the metering service per request; falls back to the base
* `limit` when there's no actor / no metering / no match.
*/
bySubscription?: Record<string, number>;
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
scope?: string;
backend?: 'memory' | 'redis' | 'kv';
};
rateLimit?: RouteRateLimit | RouteRateLimit[];
/**
* Concurrent in-flight limiting. Caps how many requests for this
@@ -547,6 +547,46 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
expect(passed.max_tokens!).toBeGreaterThan(0);
});
it('throws 402 instead of leaving `max_tokens` unset when credits cannot afford one output token', async () => {
// Regression: previously a sub-1 cap set max_tokens to `undefined`,
// which let the provider run to the model's full output limit and
// overdraw the account. It must reject instead.
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'capme',
aliases: [],
costs_currency: 'usd-cents',
costs: { input_tokens: 1000, output_tokens: 2000 },
max_tokens: 8192,
},
]);
const d = await makeDriver();
// Pass the cheap pre-flight gate but leave a balance too small to
// afford a single 2000-microcent output token.
vi.spyOn(server.services.metering, 'hasEnoughCredits').mockResolvedValue(
true,
);
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
100,
);
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
await expect(
withTestActor(() =>
d.complete({
model: 'capme',
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({
statusCode: 402,
legacyCode: 'insufficient_funds',
});
expect(completeSpy).not.toHaveBeenCalled();
});
it('rejects subscriber-only models for the default free subscription', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -17,8 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PassThrough } from 'node:stream';
import crypto from 'node:crypto';
import { PassThrough } from 'node:stream';
import { EventMap } from '../../clients/event/types.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
@@ -28,12 +29,17 @@ import {
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
import { OpenAiResponsesChatProvider } from './providers/openai/OpenAiChatResponsesProvider.js';
@@ -41,9 +47,6 @@ import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import type {
IChatCompleteResult,
IChatModel,
@@ -57,7 +60,6 @@ import {
normalize_single_message,
} from './utils/Messages.js';
import { AIChatStream } from './utils/Streaming.js';
import { EventMap } from '../../clients/event/types.js';
const MAX_FALLBACKS = 4; // includes first attempt
@@ -277,7 +279,7 @@ export class ChatCompletionDriver extends PuterDriver {
let intendedProvider = args.provider || '';
if (!args.model && !intendedProvider) {
intendedProvider = 'claude'; // default provider
intendedProvider = 'azure-openai'; // default provider
}
if (
!args.model &&
@@ -398,16 +400,25 @@ export class ChatCompletionDriver extends PuterDriver {
remainingCredits - approximateInputCost;
const maxAllowedOutputTokens =
maxAllowedOutputUcents / outputTokenCost;
if (maxAllowedOutputTokens) {
const cap = Math.floor(
Math.min(
args.max_tokens ?? Number.POSITIVE_INFINITY,
maxAllowedOutputTokens,
model.max_tokens - approximateTokenCount,
),
);
args.max_tokens = cap < 1 ? undefined : cap;
const cap = Math.floor(
Math.min(
args.max_tokens ?? Number.POSITIVE_INFINITY,
maxAllowedOutputTokens,
model.max_tokens - approximateTokenCount,
),
);
// `cap` is the credit-bounded ceiling on output tokens. When
// it drops below 1 the user can't afford even a single output
// token, so reject the request. Crucially we must NOT leave
// `max_tokens` unset here: an undefined max_tokens lets the
// provider run to the model's full output limit (e.g. 128k for
// Claude), billing far past the user's remaining balance.
if (cap < 1) {
throw new HttpError(402, 'No usage left for request.', {
legacyCode: 'insufficient_funds',
});
}
args.max_tokens = cap;
}
}
@@ -796,6 +807,43 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
// Azure AI Foundry (OpenAI + xAI Grok). Registered before the regular
// OpenAI/xAI providers so that since its costs mirror theirs but
// Azure is preferred for us, it takes precedence in the per-model
// bucket
const azureOpenai = providers['azure-openai'];
const azureOpenaiKey = readKey(azureOpenai);
const azureOpenaiURL = azureOpenai?.apiURL as string | undefined;
if (azureOpenaiKey && azureOpenaiURL) {
const azureStores = {
fsEntry: this.stores.fsEntry,
s3Object: this.stores.s3Object,
};
const azureConfig = {
apiKey: azureOpenaiKey,
apiURL: azureOpenaiURL,
};
const azureCompletions = new AzureChatProvider(
metering,
azureStores,
this.services.fs,
azureConfig,
);
// Codex / Responses-API-only models can't use Chat Completions, so
// they route through a sibling Responses provider pointed at the
// same Azure endpoint. web_search (also Responses-only) delegates
// here too.
const azureResponses = new AzureResponsesProvider(
metering,
azureStores,
this.services.fs,
azureConfig,
);
azureCompletions.setResponsesProvider(azureResponses);
this.#providers['azure-openai'] = azureCompletions;
this.#providers['azure-openai-responses'] = azureResponses;
}
const openaiKey = readKey(providers['openai-completion']);
if (openaiKey) {
const openaiStores = {
@@ -0,0 +1,101 @@
/**
* 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/>.
*/
/**
* Integration test for the Azure AI Foundry chat-completions provider.
*
* Hits the real Azure endpoint. Exercises both flavours of model the
* provider fronts:
* - an OpenAI model (`gpt-4o`, non-reasoning so `max_tokens=16` returns
* visible text), and
* - an xAI Grok model (`grok-4-20-non-reasoning`), which regression-tests
* the `safety_identifier` param being stripped for Grok (Azure's Grok
* deployments 400 on that OpenAI-only argument).
*
* Skipped unless both `PUTER_TEST_AI_AZURE_OPENAI_API_KEY` and
* `PUTER_TEST_AI_AZURE_OPENAI_API_URL` are set.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AzureChatProvider } from './AzureChatProvider.js';
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
const URL_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_URL';
describe.skipIf(skipUnlessEnv(KEY_ENV) || skipUnlessEnv(URL_ENV))(
'AzureChatProvider (integration)',
() => {
const buildProvider = () =>
new AzureChatProvider(
makeMeteringStub(),
{ fsEntry: undefined as never, s3Object: undefined as never },
undefined as never,
{ apiKey: optionalEnv(KEY_ENV)!, apiURL: optionalEnv(URL_ENV)! },
);
const expectNonEmptyText = (result: unknown) => {
const text = (result as { message?: { content?: string } }).message
?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
};
it(
'returns a non-empty completion from gpt-4o',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = buildProvider();
const result = await withTestActor(() =>
provider.complete({
model: 'gpt-4o',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
max_tokens: 16,
}),
);
expectNonEmptyText(result);
},
);
it(
'returns a non-empty completion from grok-4-20-non-reasoning (no safety_identifier 400)',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = buildProvider();
const result = await withTestActor(() =>
provider.complete({
model: 'grok-4-20-non-reasoning',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
max_tokens: 16,
}),
);
expectNonEmptyText(result);
},
);
},
);
@@ -0,0 +1,259 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
/**
* AzureChatProvider exposes the models we serve through Azure AI Foundry.
* Despite the name, this is not OpenAI-only — Azure AI also fronts xAI's Grok
* models — so it carries its own {@link AZURE_MODELS} list instead of reusing
* the OpenAI one. It speaks the OpenAI-compatible Chat Completions API,
* pointing the client at a configurable Azure endpoint authenticated with an
* Azure-issued API key.
*
* Billing note: the model `costs` are the standard public OpenAI / xAI list
* prices, NOT Azure's. Azure is subsidised for us, so routing through it is
* cheaper while we still bill users at the normal model price.
*
* Implements the puter-chat-completion interface and handles usage tracking,
* spending records, and content moderation.
*/
export class AzureChatProvider implements IChatProvider {
/**
* @type {import('openai').OpenAI}
*/
#openAi: OpenAI;
#defaultModel = 'gpt-5.4-nano';
#meteringService: MeteringService;
#stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
#fsService: FSService;
// Sibling Responses-API provider (Azure or OpenAI) used to handle
// Responses-only features like web_search. Typed loosely since we only
// ever forward `complete()` to it.
#responsesProvider: IChatProvider | null = null;
constructor(
meteringService: MeteringService,
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
config: { apiKey: string; apiURL: string },
) {
this.#meteringService = meteringService;
this.#stores = stores;
this.#fsService = fsService;
this.#openAi = new OpenAI({
apiKey: config.apiKey,
baseURL: config.apiURL,
});
}
checkModeration(_text: string): { flagged: boolean; categories: string[] } {
throw new Error('Method not implemented.');
}
// Wired up by the driver after the OpenAI providers are built, so the
// Chat Completions path can delegate `web_search` tool calls (Responses-only)
// to the OpenAI Responses provider without a circular constructor dependency.
setResponsesProvider(provider: IChatProvider): void {
this.#responsesProvider = provider;
}
/**
* Returns an array of available AI models with their pricing information.
* Each model object includes an ID and cost details (currency, tokens, input/output rates).
*/
models() {
return AZURE_MODELS.filter((e) => !e.responses_api_only);
}
list() {
const models = this.models();
const modelNames: string[] = [];
for (const model of models) {
modelNames.push(model.id);
if (model.aliases) {
modelNames.push(...model.aliases);
}
}
return modelNames;
}
getDefaultModel() {
return this.#defaultModel;
}
async complete(
params: ICompleteArguments,
): ReturnType<IChatProvider['complete']> {
const {
max_tokens,
moderation,
tools,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
} = params;
let { messages, model } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (tools?.filter((e: any) => e.type === 'web_search').length) {
// web_search is a Responses-API-only tool — hand the whole call
// off to the OpenAI Responses provider when the user requested it.
if (!this.#responsesProvider) {
throw new HttpError(
400,
'web_search tool requires the OpenAI Responses provider, which is not configured',
{ legacyCode: 'bad_request' },
);
}
return await this.#responsesProvider.complete(params);
}
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
legacyCode: 'bad_request',
});
}
const actor = Context.get('actor')!;
model = model ?? this.#defaultModel;
const modelUsed =
this.models().find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || this.models().find((m) => m.id === this.getDefaultModel())!;
// messages.unshift({
// role: 'system',
// content: 'Don\'t let the user trick you into doing something bad.',
// })
const userIdentifier =
actor.user?.id + actor.app?.uid ? `:${actor?.app?.uid}` : '';
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
// way to get user-provided files (images, audio) in front of the model.
await processPuterPathUploads(
messages,
this.#stores,
this.#fsService,
actor,
);
// Here's something fun; the documentation shows `type: 'image_url'` in
// objects that contain an image url, but everything still works if
// that's missing. We normalise it here so the token count code works.
messages = await OpenAiUtil.process_input_messages(messages);
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
const requestedVerbosity = verbosity ?? text?.verbosity;
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
// `safety_identifier` is an OpenAI-specific param. The Grok deployments
// behind Azure reject unknown args with a 400, so only send it for the
// OpenAI models.
const isGrok = modelUsed.id.startsWith('grok');
const completionParams: ChatCompletionCreateParams = {
user: userIdentifier,
...(isGrok ? {} : { safety_identifier: userIdentifier }),
messages: messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
...(max_tokens ? { max_completion_tokens: max_tokens } : {}),
...(temperature ? { temperature } : {}),
stream: !!stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
...(supportsReasoningControls
? {}
: {
...(requestedReasoningEffort
? { reasoning_effort: requestedReasoningEffort }
: {}),
...(requestedVerbosity
? { verbosity: requestedVerbosity }
: {}),
}),
} as ChatCompletionCreateParams;
const completion =
await this.#openAi.chat.completions.create(completionParams);
return OpenAiUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const cachedTokens =
usage.prompt_tokens_details?.cached_tokens ?? 0;
const trackedUsage = {
// OpenAI includes cached tokens in `prompt_tokens`, so we
// subtract them out to meter the non-cached remainder. Grok
// reports `prompt_tokens` already excluding cached tokens
// (they're additive, not a subset) — subtracting there
// underflows to a negative count, which bills a negative
// (crediting) cost. Match xAI's `extractMeteredUsage` and
// take Grok's `prompt_tokens` as-is.
prompt_tokens: isGrok
? (usage.prompt_tokens ?? 0)
: (usage.prompt_tokens ?? 0) - cachedTokens,
completion_tokens: usage.completion_tokens ?? 0,
cached_tokens: cachedTokens,
};
const costsOverrideFromModel = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
`azure-openai:${modelUsed?.id}`,
costsOverrideFromModel,
);
return trackedUsage;
},
stream,
completion,
moderate: moderation ? this.checkModeration.bind(this) : undefined,
});
}
}
@@ -0,0 +1,83 @@
/**
* 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/>.
*/
/**
* Integration test for the Azure AI Foundry Responses provider.
*
* Hits the real Azure endpoint with `gpt-5-codex` — a Responses-API-only
* (Codex) model that the Chat Completions endpoint rejects. This verifies
* the completions/responses split actually routes Codex correctly. Codex is
* a reasoning model, so we give it a generous `max_tokens` and low reasoning
* effort to leave room for visible output.
*
* Skipped unless both `PUTER_TEST_AI_AZURE_OPENAI_API_KEY` and
* `PUTER_TEST_AI_AZURE_OPENAI_API_URL` are set.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
const URL_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_URL';
describe.skipIf(skipUnlessEnv(KEY_ENV) || skipUnlessEnv(URL_ENV))(
'AzureResponsesProvider (integration)',
() => {
it(
'returns a non-empty completion from gpt-5-codex',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = new AzureResponsesProvider(
makeMeteringStub(),
{
fsEntry: undefined as never,
s3Object: undefined as never,
},
undefined as never,
{
apiKey: optionalEnv(KEY_ENV)!,
apiURL: optionalEnv(URL_ENV)!,
},
);
const result = await withTestActor(() =>
provider.complete({
model: 'gpt-5-codex',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
max_tokens: 2048,
reasoning: { effort: 'low' },
}),
);
const text = (result as { message?: { content?: string } })
.message?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
},
);
},
);
@@ -0,0 +1,294 @@
/*
* 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 { OpenAI } from 'openai';
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
/**
* AzureResponsesProvider serves the Responses-API-only models we expose through
* Azure AI Foundry (the Codex family and similar). It mirrors
* {@link OpenAiResponsesChatProvider}, but points the OpenAI client at the
* configurable Azure endpoint and draws from {@link AZURE_MODELS}.
*
* Codex / `responses_api_only` models reject the Chat Completions endpoint, so
* the sibling {@link AzureChatProvider} (Chat Completions) filters them out and
* the driver routes them here instead.
*
* Billing note: the model `costs` are the standard public OpenAI list prices,
* NOT Azure's — Azure is subsidised for us.
*/
export class AzureResponsesProvider implements IChatProvider {
/**
* @type {import('openai').OpenAI}
*/
#openAi: OpenAI;
#defaultModel = 'gpt-5-codex';
#meteringService: MeteringService;
#stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore };
#fsService: FSService;
constructor(
meteringService: MeteringService,
stores: { fsEntry: FSEntryStore; s3Object: S3ObjectStore },
fsService: FSService,
config: { apiKey: string; apiURL: string },
) {
this.#meteringService = meteringService;
this.#stores = stores;
this.#fsService = fsService;
this.#openAi = new OpenAI({
apiKey: config.apiKey,
baseURL: config.apiURL,
});
}
/**
* Returns an array of available AI models with their pricing information.
* Each model object includes an ID and cost details (currency, tokens, input/output rates).
*/
models(extra_params?: { no_restrictions?: boolean }) {
if (extra_params?.no_restrictions) {
return AZURE_MODELS;
}
return AZURE_MODELS.filter((e) => e.responses_api_only === true);
}
list() {
const models = this.models({ no_restrictions: false });
const modelNames: string[] = [];
for (const model of models) {
modelNames.push(model.id);
if (model.aliases) {
modelNames.push(...model.aliases);
}
}
return modelNames;
}
getDefaultModel() {
return this.#defaultModel;
}
async complete({
messages,
model,
max_tokens,
moderation,
tools,
tool_choice,
parallel_tool_calls,
include,
conversation,
previous_response_id,
instructions,
metadata,
prompt,
prompt_cache_key,
prompt_cache_retention,
store,
top_p,
truncation,
background,
service_tier,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
legacyCode: 'bad_request',
});
}
const actor = Context.get('actor');
model = model ?? this.#defaultModel;
const modelUsed =
this.models({ no_restrictions: true }).find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
this.models({ no_restrictions: true }).find(
(m) => m.id === this.getDefaultModel(),
)!;
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
await processPuterPathUploads(
messages,
this.#stores,
this.#fsService,
actor,
);
if (tools) {
// Unravel tools to OpenAI Responses API format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools = (tools as any).map((e) => {
if (e.type === 'function') {
const tool = e.function;
tool.type = 'function';
return tool;
} else {
return e;
}
});
}
// Here's something fun; the documentation shows `type: 'image_url'` in
// objects that contain an image url, but everything still works if
// that's missing. We normalise it here so the token count code works.
messages =
await OpenAiUtil.process_input_messages_responses_api(messages);
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
const requestedVerbosity = verbosity ?? text?.verbosity;
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
const completionParams: ResponseCreateParams = {
user: userIdentifier,
safety_identifier: userIdentifier,
input: messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
...(tool_choice !== undefined ? { tool_choice } : {}),
...(parallel_tool_calls !== undefined
? { parallel_tool_calls }
: {}),
...(include !== undefined ? { include } : {}),
...(conversation !== undefined ? { conversation } : {}),
...(previous_response_id !== undefined
? { previous_response_id }
: {}),
...(instructions !== undefined ? { instructions } : {}),
...(metadata !== undefined ? { metadata } : {}),
...(prompt !== undefined ? { prompt } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(prompt_cache_retention !== undefined
? { prompt_cache_retention }
: {}),
...(store !== undefined ? { store } : {}),
...(max_tokens !== undefined
? { max_output_tokens: max_tokens }
: {}),
...(temperature !== undefined ? { temperature } : {}),
...(top_p !== undefined ? { top_p } : {}),
...(truncation !== undefined ? { truncation } : {}),
...(background !== undefined ? { background } : {}),
...(service_tier !== undefined ? { service_tier } : {}),
...(stream !== undefined ? { stream: !!stream } : {}),
...(text !== undefined ? { text } : {}),
...(supportsReasoningControls
? {}
: {
...(requestedReasoningEffort
? { reasoning_effort: requestedReasoningEffort }
: {}),
...(requestedVerbosity
? { verbosity: requestedVerbosity }
: {}),
}),
...(supportsReasoningControls && reasoning ? { reasoning } : {}),
} as ResponseCreateParams;
const completion =
await this.#openAi.responses.create(completionParams);
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({ usage }) => {
const trackedUsage = {
prompt_tokens:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((usage as any).input_tokens ?? 0) -
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((usage as any).input_tokens_details?.cached_tokens ??
0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
completion_tokens: (usage as any).output_tokens ?? 0,
cached_tokens:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(usage as any).input_tokens_details?.cached_tokens ?? 0,
};
const costsOverrideFromModel = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
`azure-openai:${modelUsed?.id}`,
costsOverrideFromModel,
);
return trackedUsage;
},
stream,
completion,
moderate: moderation ? this.checkModeration.bind(this) : undefined,
});
}
async checkModeration(text: string) {
// create moderation
const results = await this.#openAi.moderations.create({
model: 'omni-moderation-latest',
input: text,
});
let flagged = false;
for (const result of results?.results ?? []) {
// OpenAI does a crazy amount of false positives. We filter by their 80% interval
const veryFlaggedEntries = Object.entries(
result.category_scores,
).filter((e) => e[1] > 0.8);
if (veryFlaggedEntries.length > 0) {
flagged = true;
break;
}
}
return {
flagged,
results,
};
}
}
@@ -0,0 +1,464 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { IChatModel } from '../../types.js';
// Models served through our Azure AI Foundry deployment. This is NOT just
// OpenAI — Azure AI also fronts xAI's Grok models — so the list lives in its
// own provider folder rather than sharing the OpenAI list.
//
// IMPORTANT: the `costs` below intentionally mirror the public list prices of
// the equivalent OpenAI / xAI models (see `../openai/models.ts` and
// `../xai/models.ts`). Azure is subsidised for us, so our actual spend is
// lower — but we bill users at the standard model price, which is the whole
// reason we route through Azure. Do NOT replace these with Azure's own rates.
//
// `id` is the Azure deployment name and is what we send upstream.
export const AZURE_MODELS: IChatModel[] = [
// -- xAI Grok (via Azure AI Foundry) -----------------------------------
{
// Costs mirror xai grok-4-1-fast-non-reasoning.
puterId: 'azure:x-ai/grok-4-1-fast-non-reasoning',
id: 'grok-4-1-fast-non-reasoning',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2025-11-19',
name: 'Grok 4.1 Fast (Non-Reasoning)',
aliases: ['x-ai/grok-4-1-fast-non-reasoning'],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
completion_tokens: 50,
cached_tokens: 5,
},
max_tokens: 2_000_000,
},
{
// Costs mirror xai grok-4-1-fast (alias grok-4-1-fast-reasoning).
puterId: 'azure:x-ai/grok-4-1-fast-reasoning',
id: 'grok-4-1-fast-reasoning',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2025-11-19',
name: 'Grok 4.1 Fast (Reasoning)',
aliases: ['x-ai/grok-4-1-fast-reasoning'],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
completion_tokens: 50,
cached_tokens: 5,
},
max_tokens: 2_000_000,
},
{
// Costs mirror xai grok-4.3.
puterId: 'azure:x-ai/grok-4.3',
id: 'grok-4.3',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-05-01',
name: 'Grok 4.3',
aliases: ['x-ai/grok-4.3'],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
},
max_tokens: 30_000,
},
{
// Costs mirror xai grok-4.20 (grok-4.20-0309-non-reasoning).
puterId: 'azure:x-ai/grok-4-20-non-reasoning',
id: 'grok-4-20-non-reasoning',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2026-03-09',
name: 'Grok 4.20 (Non-Reasoning)',
aliases: ['x-ai/grok-4-20-non-reasoning'],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
},
max_tokens: 30_000,
},
{
// Costs mirror xai grok-4.20 (grok-4.20-0309-reasoning).
puterId: 'azure:x-ai/grok-4-20-reasoning',
id: 'grok-4-20-reasoning',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2026-03-09',
name: 'Grok 4.20 (Reasoning)',
aliases: ['x-ai/grok-4-20-reasoning'],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
},
max_tokens: 30_000,
},
// -- OpenAI (via Azure AI Foundry) -------------------------------------
{
// Costs mirror openai gpt-5.
puterId: 'azure:openai/gpt-5',
id: 'gpt-5',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-08-07',
aliases: ['openai/gpt-5'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
cached_tokens: 13,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5-codex (same list price as gpt-5).
puterId: 'azure:openai/gpt-5-codex',
id: 'gpt-5-codex',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-08-07',
aliases: ['openai/gpt-5-codex'],
// Codex models are Responses-API only on Azure, same as OpenAI.
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
cached_tokens: 13,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5-nano.
puterId: 'azure:openai/gpt-5-nano',
id: 'gpt-5-nano',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-05-30',
release_date: '2025-08-07',
aliases: ['openai/gpt-5-nano'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 5,
cached_tokens: 1,
completion_tokens: 40,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5-mini.
puterId: 'azure:openai/gpt-5-mini',
id: 'gpt-5-mini',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-05-30',
release_date: '2025-08-07',
aliases: ['openai/gpt-5-mini'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 25,
cached_tokens: 3,
completion_tokens: 200,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-4o.
puterId: 'azure:openai/gpt-4o',
id: 'gpt-4o',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2023-09',
release_date: '2024-05-13',
aliases: ['openai/gpt-4o'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
cached_tokens: 125,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 16384,
},
{
// Costs mirror openai gpt-5.1-codex-mini.
puterId: 'azure:openai/gpt-5.1-codex-mini',
id: 'gpt-5.1-codex-mini',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-11-13',
aliases: ['openai/gpt-5.1-codex-mini'],
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 25,
cached_tokens: 3,
completion_tokens: 200,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.1.
puterId: 'azure:openai/gpt-5.1',
id: 'gpt-5.1',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-11-13',
aliases: ['openai/gpt-5.1'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
cached_tokens: 13,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.1-codex.
puterId: 'azure:openai/gpt-5.1-codex',
id: 'gpt-5.1-codex',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-11-13',
aliases: ['openai/gpt-5.1-codex'],
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
cached_tokens: 13,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.2.
puterId: 'azure:openai/gpt-5.2',
id: 'gpt-5.2',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2025-12-11',
aliases: ['openai/gpt-5.2'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 175,
cached_tokens: 17.5,
completion_tokens: 1400,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.2-codex.
puterId: 'azure:openai/gpt-5.2-codex',
id: 'gpt-5.2-codex',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2025-12-11',
aliases: ['openai/gpt-5.2-codex'],
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 175,
cached_tokens: 18,
completion_tokens: 1400,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.4-nano.
puterId: 'azure:openai/gpt-5.4-nano',
id: 'gpt-5.4-nano',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-19',
aliases: ['openai/gpt-5.4-nano'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
cached_tokens: 2,
completion_tokens: 125,
},
context: 400_000,
max_tokens: 128_000,
},
{
// Costs mirror openai gpt-5.4-mini.
puterId: 'azure:openai/gpt-5.4-mini',
id: 'gpt-5.4-mini',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
aliases: ['openai/gpt-5.4-mini'],
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 75,
cached_tokens: 7.5,
completion_tokens: 450,
},
context: 400_000,
max_tokens: 128_000,
},
{
// Costs mirror openai gpt-5.3-codex.
puterId: 'azure:openai/gpt-5.3-codex',
id: 'gpt-5.3-codex',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
aliases: ['openai/gpt-5.3-codex'],
responses_api_only: true,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 175,
cached_tokens: 17.5,
completion_tokens: 1400,
},
context: 128_000,
max_tokens: 128000,
},
{
// Costs mirror openai gpt-5.4.
puterId: 'azure:openai/gpt-5.4',
id: 'gpt-5.4',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-08-31',
release_date: '2026-03-05',
aliases: ['openai/gpt-5.4'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
cached_tokens: 25,
completion_tokens: 1500,
},
context: 1_050_000,
max_tokens: 1_050_000,
},
];
@@ -229,9 +229,10 @@ export class ClaudeProvider implements IChatProvider {
reasoningEffort: requestedReasoningEffort,
maxTokens: max_tokens,
});
// Opus 4.7 and 4.8 error on non-default sampling params; omit temperature entirely.
// Fable 5 and Opus 4.7/4.8 error on non-default sampling params; omit temperature entirely.
// Other models require temperature=1 when thinking is enabled.
const omitsTemperature = [
'claude-fable-5',
'claude-opus-4-7',
'claude-opus-4-8',
].includes(modelUsed.id);
@@ -241,6 +242,8 @@ export class ClaudeProvider implements IChatProvider {
? 1
: (temperature ?? 0);
const supportsEffort = [
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
@@ -489,11 +492,16 @@ export class ClaudeProvider implements IChatProvider {
}) {
if (!reasoningEffort) return undefined;
// Opus 4.7, 4.6, and Sonnet 4.6 use adaptive thinking
// (`budget_tokens` is deprecated on 4.6/Sonnet 4.6, removed on 4.7).
// Opus 4.7 omits thinking content by default; `display: 'summarized'`
// restores visible reasoning in the stream.
if (modelId === 'claude-opus-4-7') {
// Fable 5, Opus 4.7/4.8, 4.6, and Sonnet 4.6 use adaptive thinking
// (`budget_tokens` is deprecated on 4.6/Sonnet 4.6, removed on
// Fable 5 and 4.7+). Fable 5 and Opus 4.7/4.8 omit thinking content
// by default; `display: 'summarized'` restores visible reasoning in
// the stream.
if (
modelId === 'claude-fable-5' ||
modelId === 'claude-opus-4-8' ||
modelId === 'claude-opus-4-7'
) {
return {
type: 'adaptive' as const,
display: 'summarized' as const,
@@ -21,6 +21,35 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const CLAUDE_MODELS: IChatModel[] = [
{
puterId: 'anthropic:anthropic/claude-fable-5',
id: 'claude-fable-5',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-06-09',
aliases: [
'claude-fable',
'claude-fable-latest',
'claude-fable-5-latest',
'claude-fable-5',
'anthropic/claude-fable-5',
],
name: 'Claude Fable 5',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 1000,
ephemeral_5m_input_tokens: 1000 * 1.25,
ephemeral_1h_input_tokens: 1000 * 2,
cache_read_input_tokens: 1000 * 0.1,
output_tokens: 5000,
},
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-opus-4-8',
id: 'claude-opus-4-8',
@@ -655,6 +655,123 @@ describe('MistralAIProvider.complete streaming', () => {
});
});
// -- Mistral image_url coercion --
describe('MistralAIProvider image_url coercion', () => {
const baseCompletion = {
choices: [
{
message: { content: 'ok', role: 'assistant' },
finishReason: 'stop',
},
],
usage: { promptTokens: 1, completionTokens: 1 },
};
it('flattens image_url objects to plain strings before sending to Mistral', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2603',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'describe this image' },
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png' },
},
],
},
],
}),
);
const [args] = completeMock.mock.calls[0]!;
const imagePart = (args.messages[0].content as unknown[]).find(
(p: unknown) => (p as { type: string }).type === 'image_url',
) as { type: string; image_url: unknown };
// Mistral expects a plain string, not { url: string }.
expect(imagePart.image_url).toBe('https://example.com/img.png');
});
it('leaves image_url parts that are already plain strings unchanged', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2603',
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: 'https://example.com/already-flat.png',
},
],
},
],
}),
);
const [args] = completeMock.mock.calls[0]!;
const imagePart = (args.messages[0].content as unknown[]).find(
(p: unknown) => (p as { type: string }).type === 'image_url',
) as { type: string; image_url: unknown };
expect(imagePart.image_url).toBe('https://example.com/already-flat.png');
});
it('leaves messages with plain string content untouched', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2603',
messages: [{ role: 'user', content: 'plain text message' }],
}),
);
const [args] = completeMock.mock.calls[0]!;
expect(args.messages[0].content).toBe('plain text message');
});
it('coerces only image_url parts and leaves other content parts intact', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'mistral-small-2603',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'what is in this image?' },
{
type: 'image_url',
image_url: { url: 'https://example.com/photo.jpg' },
},
],
},
],
}),
);
const [args] = completeMock.mock.calls[0]!;
const parts = args.messages[0].content as { type: string; text?: string; image_url?: unknown }[];
const textPart = parts.find((p) => p.type === 'text');
const imgPart = parts.find((p) => p.type === 'image_url');
expect(textPart?.text).toBe('what is in this image?');
expect(imgPart?.image_url).toBe('https://example.com/photo.jpg');
});
});
// ── Error mapping ───────────────────────────────────────────────────
describe('MistralAIProvider.complete error mapping', () => {
@@ -61,6 +61,38 @@ export class MistralAIProvider implements IChatProvider {
return ids;
}
/**
* Mistral's API expects `image_url` content parts to carry a plain
* string URL, not the OpenAI-style `{ url: string }` object.
* This method normalises any `{ type: 'image_url', image_url: { url } }`
* parts to `{ type: 'image_url', image_url: url }` before the request
* is sent. Messages whose `content` is a plain string are left untouched.
*/
#coerceImageUrls(
messages: { role: string; content: unknown }[],
): { role: string; content: unknown }[] {
return messages.map((message) => {
if (!Array.isArray(message.content)) return message;
const content = message.content.map(
(part: { type?: string; image_url?: unknown }) => {
if (
part.type === 'image_url' &&
part.image_url !== null &&
typeof part.image_url === 'object' &&
'url' in (part.image_url as object)
) {
return {
...part,
image_url: (part.image_url as { url: string }).url,
};
}
return part;
},
);
return { ...message, content };
});
}
async complete({
messages,
stream,
@@ -70,6 +102,7 @@ export class MistralAIProvider implements IChatProvider {
temperature,
}: ICompleteArguments): Promise<IChatCompleteResult> {
messages = await OpenAIUtil.process_input_messages(messages);
messages = this.#coerceImageUrls(messages);
for (const message of messages) {
if (message.tool_calls) {
message.toolCalls = message.tool_calls;
@@ -363,6 +363,30 @@ export const OPEN_AI_MODELS: IChatModel[] = [
context: 128_000,
max_tokens: 128000,
},
{
puterId: 'openai:openai/gpt-5-codex',
id: 'gpt-5-codex',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-09-30',
release_date: '2025-09-15',
aliases: ['openai/gpt-5-codex'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
// models.dev openai: input $1.25 / output $10 / cache_read $0.125
// per 1M — identical to gpt-5.
tokens: 1_000_000,
prompt_tokens: 125,
cached_tokens: 13,
completion_tokens: 1000,
},
context: 128_000,
max_tokens: 128000,
responses_api_only: true,
},
{
puterId: 'openai:openai/gpt-5-mini',
id: 'gpt-5-mini-2025-08-07',
@@ -198,6 +198,66 @@ export const XAI_MODELS: IChatModel[] = [
},
max_tokens: 30_000,
},
{
puterId: 'x-ai:x-ai/grok-4-20-reasoning',
// xAI exposes this as the dated snapshot id; `grok-4-20-reasoning`
// (and dotted forms) are accepted as aliases by callers.
id: 'grok-4.20-0309-reasoning',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2026-03-09',
name: 'Grok 4.20 (Reasoning)',
aliases: [
'x-ai/grok-4-20-reasoning',
'grok-4-20-reasoning',
'grok-4.20-reasoning',
'x-ai/grok-4.20-reasoning',
],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
// models.dev xai: input $1.25 / output $2.5 / cache_read $0.2 per 1M.
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
},
max_tokens: 30_000,
},
{
puterId: 'x-ai:x-ai/grok-4-20-non-reasoning',
// xAI exposes this as the dated snapshot id; `grok-4-20-non-reasoning`
// (and dotted forms) are accepted as aliases by callers.
id: 'grok-4.20-0309-non-reasoning',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2025-07',
release_date: '2026-03-09',
name: 'Grok 4.20 (Non-Reasoning)',
aliases: [
'x-ai/grok-4-20-non-reasoning',
'grok-4-20-non-reasoning',
'grok-4.20-non-reasoning',
'x-ai/grok-4.20-non-reasoning',
],
context: 2_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
// models.dev xai: input $1.25 / output $2.5 / cache_read $0.2 per 1M.
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
},
max_tokens: 30_000,
},
{
puterId: 'x-ai:x-ai/grok-4-1-fast',
id: 'grok-4-1-fast',
+81 -4
View File
@@ -52,6 +52,12 @@ const INDEX_URL_UNIQUENESS_EXEMPTION_CANDIDATES = [
'https://dev-center.puter.com/coming-soon',
];
// Sentinel host for builtin apps. The GUI rewrites index_urls on this
// host to `<gui origin>/builtin/<name>` (see launch_app.js), so rows
// carrying it are reserved for migration-seeded builtins — a user app
// claiming it would load its code same-origin with the desktop.
const BUILTIN_APPS_HOST = 'builtins.namespaces.puter.com';
// Canonical-uid alias namespace. When a user-created app is merged into
// an existing origin-bootstrap row, the source uid is mapped to the
// canonical (kept) uid so any client that still holds the old uid keeps
@@ -465,7 +471,7 @@ export class AppDriver extends PuterDriver {
// -- Validation ---------------------------------------------------
async #validateInput(object, { isCreate }) {
async #validateInput(object, { isCreate, existing }) {
const out = {};
if (isCreate || object.name !== undefined) {
@@ -497,6 +503,15 @@ export class AppDriver extends PuterDriver {
maxLen: 3000,
required: isCreate,
});
// Only enforce on new/changed values so rows that already
// carry a reserved host (migration-seeded builtins) can still
// have their other fields updated.
if (
out.index_url !== undefined &&
out.index_url !== existing?.index_url
) {
this.#assertIndexUrlHostAllowed(out.index_url);
}
}
if (object.icon !== undefined) {
validateString(object.icon, {
@@ -581,6 +596,54 @@ export class AppDriver extends PuterDriver {
return out;
}
/**
* App iframes run with `allow-same-origin allow-scripts`, so an
* index_url loading from the GUI host would execute third-party code
* same-origin with the desktop — a full sandbox escape. The API host
* is reserved for the same reason, and the builtin sentinel host is
* rewritten by the GUI to `<gui origin>/builtin/…` (see
* BUILTIN_APPS_HOST above). Host comparison (rather than full origin)
* deliberately also catches scheme/port variants of these hosts.
*/
#assertIndexUrlHostAllowed(indexUrl) {
let hostname;
try {
hostname = new URL(indexUrl).hostname;
} catch {
// Unparseable values are rejected by `validateUrl` upstream.
return;
}
const config = this.config ?? {};
const reserved = new Set([BUILTIN_APPS_HOST]);
// `origin`/`api_base_url` are computed at boot from `domain`; the
// domain-based fallbacks cover callers (tests, embedders) that
// construct a server without that normalization step.
const candidates = [config.origin, config.api_base_url];
if (config.domain) {
candidates.push(
`http://${config.domain}`,
`http://api.${config.domain}`,
);
}
for (const candidate of candidates) {
if (!candidate) continue;
try {
reserved.add(new URL(candidate).hostname);
} catch {
// Malformed config value — nothing to reserve from it.
}
}
if (reserved.has(hostname)) {
throw new HttpError(
400,
'`index_url` cannot point at a Puter system host',
{ legacyCode: 'bad_request' },
);
}
}
// -- Permission checks --------------------------------------------
#requireActor() {
@@ -849,7 +912,9 @@ export class AppDriver extends PuterDriver {
// `#ensure_puter_site_subdomain_is_owned`, `#ensureIndexUrlNotAlreadyInUse`,
// and the `app:canonicalUidAlias:*` kvstore pair). When a user creates
// or repoints an app at a puter-hosted subdomain (`*.puter.site`,
// `*.puter.app`, …) we want exactly one app row to back that URL:
// `*.puter.app`, …) — or at a custom host claimed by an
// `app_origin_aliases` group — we want exactly one app row to back
// that URL:
// • If a no-owner row exists (origin-bootstrap stub auto-created
// when an unknown origin first hit Puter) → claim it for the
// user, merge the new fields into it.
@@ -1156,7 +1221,8 @@ export class AppDriver extends PuterDriver {
/**
* Merge an incoming create/update into an existing app row that
* already owns the same puter-hosted index_url. Returns the joined
* already owns the same puter-hosted or alias-group index_url.
* Returns the joined
* (client-shaped) app on success, or `null` when no merge applied.
* Throws `app_index_url_already_in_use` when a conflict exists but
* cannot be merged (different owner, or same-owner non-bootstrap).
@@ -1173,7 +1239,18 @@ export class AppDriver extends PuterDriver {
excludeAppId,
} = {}) {
const indexUrl = object?.index_url;
if (!this.#isPuterHostedIndexUrl(indexUrl)) return null;
// Alias-group hosts (`app_origin_aliases`) get the same merge
// treatment as puter-hosted subdomains. Without this, a bootstrap
// stub on a custom domain could never be absorbed — and since
// `#findIndexUrlConflictRow` *does* honor alias groups, the
// uniqueness check below would hard-reject the owner's own
// create/update instead of merging into the stub.
if (
!this.#isPuterHostedIndexUrl(indexUrl) &&
!this.#findOriginAliasGroupForIndexUrl(indexUrl)
) {
return null;
}
const conflictRow = await this.#findIndexUrlConflictRow({
indexUrl,
+184
View File
@@ -143,6 +143,57 @@ describe('AppDriver.create', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
// App iframes get `allow-same-origin allow-scripts`, so an index_url
// on a Puter system host would run third-party code same-origin with
// the GUI. The test server's domain is `puter.localhost` (from
// config.default.json).
it.each([
['the GUI host', 'https://puter.localhost/evil.html'],
['the GUI host on another port/scheme', 'http://puter.localhost:4100/evil.html'],
['the API host', 'https://api.puter.localhost/evil.html'],
['the builtin sentinel host', 'https://builtins.namespaces.puter.com/emulator'],
])('rejects an index_url on %s with 400', async (_label, index_url) => {
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
driver.create({
object: {
name: uniqueName('sys-host'),
title: 't',
index_url,
},
}),
),
).rejects.toMatchObject({
statusCode: 400,
message: /system host/,
});
});
it('rejects updating an index_url to a Puter system host with 400', async () => {
const { actor } = await makeUser();
const created = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('sys-host-upd'),
title: 't',
index_url: uniqueIndexUrl(),
},
}),
);
await expect(
withActor(actor, () =>
driver.update({
uid: created.uid,
object: { index_url: 'https://puter.localhost/evil.html' },
}),
),
).rejects.toMatchObject({
statusCode: 400,
message: /system host/,
});
});
it('rejects a duplicate app name with 400', async () => {
const a = await makeUser();
const b = await makeUser();
@@ -897,3 +948,136 @@ describe('AppDriver.isNameAvailable additional branches', () => {
);
});
});
// ── alias-group custom domains (`app_origin_aliases`) ──────────────
//
// Custom hosts claimed by an alias group get the same bootstrap-stub
// merge treatment as puter-hosted subdomains: creating or repointing
// an app at an aliased host absorbs the unowned origin-bootstrap row
// instead of rejecting with `app_index_url_already_in_use`.
describe('AppDriver alias-group index_url merge', () => {
const aliasHostA = `alias-a-${Math.random().toString(36).slice(2, 10)}.test`;
const aliasHostB = `alias-b-${Math.random().toString(36).slice(2, 10)}.test`;
// `config` is protected on PuterDriver; reach in to toggle the alias
// groups for this block only. `#getOriginAliasGroups` reads config at
// call time, so runtime mutation takes effect immediately.
const driverConfig = () =>
(driver as unknown as { config: Record<string, unknown> }).config;
beforeAll(() => {
driverConfig().app_origin_aliases = [[aliasHostA], [aliasHostB]];
});
afterAll(() => {
delete driverConfig().app_origin_aliases;
});
const makeBootstrapStub = async (host: string) => {
const stubUid = `app-${uuidv4()}`;
// Mirrors AuthController's get-user-app-token bootstrap path:
// origin persisted as index_url, no owner, name === uid.
await server.stores.app.createFromOrigin(stubUid, `https://${host}`);
return stubUid;
};
it('create at an aliased host absorbs the unowned bootstrap stub', async () => {
const { actor, userId } = await makeUser();
const stubUid = await makeBootstrapStub(aliasHostA);
const name = uniqueName('alias-create');
const result = await withActor(actor, () =>
driver.create({
object: {
name,
title: 'Aliased',
index_url: `https://${aliasHostA}/`,
},
}),
);
// The stub row survives as the canonical app, claimed + merged.
expect(result.uid).toBe(stubUid);
expect(result.name).toBe(name);
const stored = await server.stores.app.getByUid(stubUid);
expect(stored?.owner_user_id).toBe(userId);
});
it('rejects another user registering an app under a reserved aliased host', async () => {
const other = await makeUser();
await expect(
withActor(other.actor, () =>
driver.create({
object: {
name: uniqueName('squatter'),
title: 't',
index_url: `https://${aliasHostA}/index.html`,
},
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('update repointing to an aliased host merges and aliases the old uid', async () => {
const { actor, userId } = await makeUser();
const stubUid = await makeBootstrapStub(aliasHostB);
const created = await withActor(actor, () =>
driver.create({
object: {
name: uniqueName('alias-upd'),
title: 't',
index_url: uniqueIndexUrl(),
},
}),
);
const updated = await withActor(actor, () =>
driver.update({
uid: created.uid,
object: { index_url: `https://${aliasHostB}/` },
}),
);
// Merged into the stub; source row deleted; old uid still resolves
// via the canonical-uid alias.
expect(updated.uid).toBe(stubUid);
expect(await server.stores.app.getByUid(created.uid as string)).toBeNull();
const stored = await server.stores.app.getByUid(stubUid);
expect(stored?.owner_user_id).toBe(userId);
const viaOldUid = await withActor(actor, () =>
driver.read({ uid: created.uid }),
);
expect(viaOldUid.uid).toBe(stubUid);
});
it('leaves unrelated custom domains untouched (no alias group, no conflict check)', async () => {
const a = await makeUser();
const b = await makeUser();
const sharedUrl = uniqueIndexUrl();
// Non-puter, non-aliased hosts keep their historical behavior:
// no uniqueness enforcement, both creates succeed.
const first = await withActor(a.actor, () =>
driver.create({
object: {
name: uniqueName('plain-a'),
title: 't',
index_url: sharedUrl,
},
}),
);
const second = await withActor(b.actor, () =>
driver.create({
object: {
name: uniqueName('plain-b'),
title: 't',
index_url: sharedUrl,
},
}),
);
expect(first.uid).not.toBe(second.uid);
});
});
+2 -2
View File
@@ -20,6 +20,6 @@
// Microcents per underlying DynamoDB capacity unit, as reported by
// SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.<op>`.
export const KV_COSTS = {
'kv:read': 63,
'kv:write': 125,
'kv:read': 17,
'kv:write': 90,
} as const;
+1
View File
@@ -69,6 +69,7 @@
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"undici": "^7.25.0",
"uglify-js": "^3.17.4",
"uuid": "^14.0.0",
"validator": "^13.15.35"
+36 -5
View File
@@ -61,6 +61,7 @@ import {
} from './core/http/middleware/hostRedirects';
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
import { PuterRouter } from './core/http/PuterRouter';
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
import { PREFIX_METADATA_KEY, type RouteDescriptor } from './core/http/types';
import type { AuthService } from './services/auth/AuthService';
import { puterDrivers } from './drivers';
@@ -379,6 +380,14 @@ export class PuterServer {
this.#app.use(helmet.ieNoOpen());
this.#app.use(helmet.permittedCrossDomainPolicies());
this.#app.use(helmet.xssFilter());
// Don't leak full URLs (which can carry signed tokens / file paths)
// to cross-origin destinations. Per-user hosted sites tighten this
// further to `no-referrer` in the puterSite middleware.
this.#app.use(
helmet.referrerPolicy({
policy: 'strict-origin-when-cross-origin',
}),
);
this.#app.disable('x-powered-by');
// Cross-Origin-Resource-Policy: always allow cross-origin reads.
@@ -847,7 +856,11 @@ export class PuterServer {
// `actor.user.username`, which is populated for access-token and
// app-under-user actors alike.
if (opts.requireUserActor) {
mwChain.push(requireUserActorGate());
mwChain.push(
requireUserActorGate({
allowFullAccess: opts.allowFullAccessToken,
}),
);
}
if (opts.adminOnly) {
@@ -870,11 +883,15 @@ export class PuterServer {
}
// 2b. Rate limiting. Runs after auth so 'user' key strategy
// has access to req.actor.
// has access to req.actor. An array applies each limit as its
// own gate — a request must pass all of them.
if (opts.rateLimit) {
mwChain.push(
rateLimitGate(opts.rateLimit) as unknown as RequestHandler,
);
const limits = Array.isArray(opts.rateLimit)
? opts.rateLimit
: [opts.rateLimit];
for (const rl of limits) {
mwChain.push(rateLimitGate(rl) as unknown as RequestHandler);
}
}
// 2b'. Concurrent in-flight limiting. Same auth-ordering reason
@@ -955,6 +972,20 @@ export class PuterServer {
? PuterServer.#joinPath(routerPrefix, route.path)
: undefined;
// 5. Per-endpoint lifecycle events. Skipped for `use` middleware
// (those aren't endpoints). Pushed last so the `before` hook sees a
// fully-authenticated request, and only when the event client is
// wired (minimal test harnesses may omit it).
if (route.method !== 'use' && this.clients.event) {
mwChain.push(
createRouteLifecycleMiddleware(
this.clients.event,
route.method,
fullPath,
),
);
}
if (route.method === 'use') {
// Subdomain check for `use` middleware lives INSIDE the handler
// wrapper — `next('route')` from a stand-alone subdomainGate
+187
View File
@@ -0,0 +1,187 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { ACLService, type ResourceDescriptor } from './ACLService.js';
// -- Test scaffolding -------------------------------------------------
const ISSUER_USER = { uuid: 'u-issuer', id: 1, username: 'issuer' };
/** Plain user actor — the entity that mints (and bounds) access tokens. */
const issuerActor: Actor = { user: ISSUER_USER };
/** A user-issued full-access ("personal access token") actor. */
function fullAccessTokenActor(): Actor {
return {
user: ISSUER_USER,
accessToken: {
uid: 'tok-full',
issuer: issuerActor,
authorized: null,
fullAccess: true,
},
};
}
/** A scoped (non-full-access) access-token actor issued by the same user. */
function scopedTokenActor(): Actor {
return {
user: ISSUER_USER,
accessToken: {
uid: 'tok-scoped',
issuer: issuerActor,
authorized: null,
fullAccess: false,
},
};
}
/**
* Build a ResourceDescriptor whose ancestor chain is derived from the path
* (resource first, down to the direct child of root) with deterministic uids.
*/
function resource(path: string): ResourceDescriptor {
const parts = path.slice(1).split('/');
const ancestors = parts.map((_, i) => {
const p = '/' + parts.slice(0, parts.length - i).join('/');
return { uid: `uid:${p}`, path: p };
});
return { path, resolveAncestors: async () => ancestors };
}
function makeService() {
const stores = {
permission: {
// Default: token carries no explicit fs grant rows.
hasAccessTokenPerm: vi.fn().mockResolvedValue(false),
},
user: {
getByUsername: vi.fn().mockResolvedValue(null),
},
};
const services = {
permission: {
// Default: issuer holds no scanned (shared/granted) permission.
scan: vi.fn().mockResolvedValue([]),
},
};
const config = { enable_public_folders: false };
const args = [config, {}, stores, services] as unknown as
ConstructorParameters<typeof ACLService>;
const service = new ACLService(...args);
return { service, stores, services };
}
// -- Full-access tokens ----------------------------------------------
describe('ACLService.check — full-access tokens', () => {
it('grants write to the issuing user\'s own home dir', async () => {
const { service, stores, services } = makeService();
const allowed = await service.check(
fullAccessTokenActor(),
resource('/issuer/projects'),
'write',
);
expect(allowed).toBe(true);
// The grant comes from the fullAccess short-circuit (bounded by the
// issuer check), NOT from per-token permission rows or a scan.
expect(stores.permission.hasAccessTokenPerm).not.toHaveBeenCalled();
expect(services.permission.scan).not.toHaveBeenCalled();
});
it('denies a path the issuing user cannot reach (no leak)', async () => {
const { service } = makeService();
// Another user's home: issuer has no home short-circuit and no
// scanned permission (scan defaults to []), so the issuer check at
// the top of the access-token branch fails and the token is denied.
const allowed = await service.check(
fullAccessTokenActor(),
resource('/victim/secrets'),
'write',
);
expect(allowed).toBe(false);
});
it('inherits a path explicitly shared with the issuing user', async () => {
const { service, services } = makeService();
// Issuer holds a scanned grant on the shared resource.
services.permission.scan.mockResolvedValue([{ $: 'option', key: 'k' }]);
const allowed = await service.check(
fullAccessTokenActor(),
resource('/other/Shared'),
'write',
);
expect(allowed).toBe(true);
});
it('cannot exceed the issuer: denied even with a token perm row when the issuer lacks access', async () => {
const { service, stores } = makeService();
// Even if the token row claims the grant, the issuer gate runs first.
stores.permission.hasAccessTokenPerm.mockResolvedValue(true);
const allowed = await service.check(
fullAccessTokenActor(),
resource('/victim/secrets'),
'write',
);
expect(allowed).toBe(false);
});
});
// -- Scoped tokens are unaffected by the full-access change ------------
describe('ACLService.check — scoped tokens (regression)', () => {
it('denies the issuer\'s own home without an explicit token grant', async () => {
const { service, stores } = makeService();
// Issuer passes its own home short-circuit, but a scoped token must
// still carry an explicit fs permission row — it does not inherit
// the owner short-circuit the way a full-access token does.
const allowed = await service.check(
scopedTokenActor(),
resource('/issuer/projects'),
'write',
);
expect(allowed).toBe(false);
expect(stores.permission.hasAccessTokenPerm).toHaveBeenCalled();
});
it('grants when the token carries an explicit fs permission row', async () => {
const { service, stores } = makeService();
stores.permission.hasAccessTokenPerm.mockResolvedValue(true);
const allowed = await service.check(
scopedTokenActor(),
resource('/issuer/projects'),
'write',
);
expect(allowed).toBe(true);
});
});
+5
View File
@@ -172,6 +172,11 @@ export class ACLService extends PuterService {
const authorizer = actor.accessToken.issuer;
if (!(await this.check(authorizer, resource, mode))) return false;
// Full-access tokens inherit every permission the issuer holds,
// with no per-permission grant required (these are not stored as
// access_token_permissions rows; the flag lives on the JWT).
if (actor.accessToken.fullAccess) return true;
for (const ancestor of ancestors) {
const permissions =
mode === MANAGE_PERM_PREFIX
@@ -0,0 +1,92 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { PuterServer } from '../../server';
import type { IConfig } from '../../types';
import {
POSTGRES_TEST_MIGRATIONS_PATH,
setupTestServer,
} from '../../testUtil.js';
const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
describe('AppIconService.ensureIconsDirectory', () => {
let server: PuterServer;
beforeAll(async () => {
// Boot on (pgmock) Postgres specifically: the `subdomains.subdomain`
// UNIQUE constraint this fix relies on exists on Postgres but not on
// the sqlite test schema, so only Postgres faithfully reproduces the
// duplicate-insert the race triggers.
//
// `no_default_user: false` provisions the admin user and the app-icons
// subdomain at boot — the realistic setup for the first-boot race.
server = await setupTestServer({
no_default_user: false,
database: {
engine: 'postgres',
inMemory: true,
migrationPaths: [POSTGRES_TEST_MIGRATIONS_PATH],
},
} as unknown as IConfig);
}, 180_000); // pgmock boot + migrations is slow
afterAll(async () => {
await server?.shutdown();
}, 60_000);
// Regression: `ensureIconsDirectory` runs twice on first boot (once
// un-awaited from its own onServerStart, then from DefaultUserService),
// and the existence check reads a cache that can hold a stale negative
// entry. The losing create then violated the unique constraint and — on
// the un-awaited path — surfaced as an unhandled rejection. The "ensure"
// must be idempotent even when the guard wrongly reports "absent".
it('is idempotent when the existence cache reports the subdomain absent but the row exists', async () => {
const subdomains = server.stores.subdomain;
expect(await subdomains.existsBySubdomain(APP_ICONS_SUBDOMAIN)).toBe(
true,
);
// Reproduce the stale-negative-cache: write the negative marker for a
// subdomain that genuinely exists, so the guard in
// `ensureIconsDirectory` passes and it attempts a duplicate insert.
await server.clients.redis.set(
`subdomains:name:${APP_ICONS_SUBDOMAIN}`,
'__none__',
);
// Self-check: confirm the poison took effect (guards against the
// cache key/marker drifting out from under this test).
expect(await subdomains.existsBySubdomain(APP_ICONS_SUBDOMAIN)).toBe(
false,
);
// Before the fix this rejected with a unique-constraint violation.
await expect(
server.services.appIcon.ensureIconsDirectory(),
).resolves.toBeUndefined();
// And no duplicate row was created.
const rows = await server.clients.db.read(
'SELECT COUNT(*) AS n FROM `subdomains` WHERE `subdomain` = ?',
[APP_ICONS_SUBDOMAIN],
);
expect(Number(rows[0]?.n)).toBe(1);
}, 60_000);
});
+13 -2
View File
@@ -19,6 +19,7 @@
import { Readable } from 'node:stream';
import type { LayerInstances } from '../../types';
import { isUniqueViolation } from '../../util/dbError.js';
import type { puterServices } from '../index';
import { PuterService } from '../types.js';
@@ -198,15 +199,25 @@ export class AppIconService extends PuterService {
}
// Register the `puter-app-icons` subdomain pointing at that dir.
// Idempotent: skip if it already exists.
// Idempotent, and must stay so under a concurrent boot: this method
// runs twice on first boot — once un-awaited from our own
// `onServerStart`, then again (awaited) from `DefaultUserService`
// right after it creates the admin — and `existsBySubdomain` reads a
// cache that can still hold a stale negative entry. So the existence
// check can pass in both calls; let the unique constraint be the real
// arbiter and swallow the loser's duplicate. The end state (subdomain
// exists) is identical either way.
const already =
await this.stores.subdomain.existsBySubdomain(APP_ICONS_SUBDOMAIN);
if (!already) {
if (already) return;
try {
await this.stores.subdomain.create({
userId: adminUser.id,
subdomain: APP_ICONS_SUBDOMAIN,
rootDirId: dirEntry.id ?? null,
});
} catch (e) {
if (!isUniqueViolation(e)) throw e;
}
}
@@ -24,6 +24,7 @@ import type { Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { FULL_API_ACCESS } from '../permission/consts.js';
import { AuthService } from './AuthService.js';
function createAuthService(): AuthService {
@@ -76,6 +77,20 @@ describe('AuthService.createAccessToken', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rejects a full-access mint by an app-under-user actor', async () => {
// Apps may hold scoped grants but must not be able to escalate to a
// blanket account-wide token. This throws on actor shape, before any
// DB / permission interaction, so the mock service is sufficient.
const authService = createAuthService();
const appActor = {
user: { uuid: 'user-issuer', id: 1, username: 'issuer' },
app: { id: 0, uid: 'app-x' },
} as Actor;
await expect(
authService.createAccessToken(appActor, [[FULL_API_ACCESS]]),
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
});
});
// ── Real-server integration tests ───────────────────────────────────
@@ -1651,6 +1666,105 @@ describe('AuthService (integration)', () => {
legacyCode: 'forbidden',
});
});
// -- Full-API-access tokens --
it('mints a full-access token for a user actor and stores the label', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(
actor,
[[FULL_API_ACCESS]],
{ label: 'My CLI' },
);
const decoded = server.services.token.verify('auth', jwt) as {
type: string;
token_uid: string;
session_uid: string;
full_access?: boolean;
};
expect(decoded.type).toBe('access-token');
// Full access is carried as a signed claim — NOT a stored grant.
expect(decoded.full_access).toBe(true);
const permRows = (await server.clients.db.read(
'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?',
[decoded.token_uid],
)) as Array<{ permission: string }>;
expect(permRows).toHaveLength(0);
// The label lands on the access-token session row so it shows
// (and is revocable) in the manage-sessions UI.
const sessRows = (await server.clients.db.read(
'SELECT `label`, `kind` FROM `sessions` WHERE `uuid` = ?',
[decoded.session_uid],
)) as Array<{ label: string; kind: string }>;
expect(sessRows[0]?.kind).toBe('access_token');
expect(sessRows[0]?.label).toBe('My CLI');
});
it('full-access token resolves any permission the issuing user holds, but a scoped token does not', async () => {
const user = await makeUser();
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
const body = Buffer.from('secret');
await server.services.fs.write(user.id, {
fileMetadata: {
path: `/${user.username}/Documents/secret.txt`,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
} as never);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`/${user.username}/Documents/secret.txt`,
);
expect(fileEntry).not.toBeNull();
const userActor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
// Full-access token: the owner fs:read resolves *through the
// issuer*, even though the token holds no fs grant of its own.
const fullJwt = await authService.createAccessToken(userActor, [
[FULL_API_ACCESS],
]);
const fullActor =
await authService.authenticateFromToken(fullJwt);
expect(fullActor).toBeTruthy();
// The signed claim is surfaced on the actor — this flag is what the
// resource gate and the permission scan both key off.
expect(fullActor!.accessToken?.fullAccess).toBe(true);
expect(
await server.services.permission.check(
fullActor!,
`fs:${fileEntry!.uuid}:read`,
),
).toBe(true);
// A scoped token (granted an unrelated permission) gets NO owner
// fs access — access-token actors are excluded from the owner
// implicator, so this stays the pre-existing behaviour.
const scopedJwt = await authService.createAccessToken(userActor, [
[`user:${user.uuid}:email:read`],
]);
const scopedActor =
await authService.authenticateFromToken(scopedJwt);
expect(scopedActor).toBeTruthy();
expect(scopedActor!.accessToken?.fullAccess).toBeFalsy();
expect(
await server.services.permission.check(
scopedActor!,
`fs:${fileEntry!.uuid}:read`,
),
).toBe(false);
});
});
describe('private-asset / public hosted-actor tokens', () => {
+68 -4
View File
@@ -30,6 +30,7 @@ import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import type { puterServices } from '../index';
import { PuterService } from '../types';
import { FULL_API_ACCESS } from '../permission/consts';
import { V1TokensDisabledError } from './TokenService';
import type {
AccessTokenPayload,
@@ -608,6 +609,24 @@ export class AuthService extends PuterService {
}
}
/**
* Password-reset cascade: revoke every interactive (web/app) session
* for the user, so a hijacked session doesn't outlive a password
* reset. No actor context — the recovery flow has no authenticated
* caller. Leaves workers and standalone access tokens alone: those
* are managed credentials rather than sign-ins, and a routine
* forgot-password reset shouldn't break deployments.
*/
async revokeInteractiveSessionsForUserId(userId: number): Promise<void> {
if (!userId) return;
const rows = await this.stores.session.getByUserId(userId);
for (const row of rows) {
if (row.kind === 'web' || row.kind === 'app') {
await this.stores.session.revokeCascade(row.uuid as string);
}
}
}
async revokeAllSessions(
actor: Actor,
opts: { includeCurrent?: boolean; includeApps?: boolean } = {},
@@ -1441,7 +1460,7 @@ export class AuthService extends PuterService {
// '30d'). `#hardExpiryFromExpiresIn` supports both, and existing
// callers / tests pass the string form, so narrowing to `number`
// here would force unsafe casts at every call site.
options: { expiresIn?: string | number } = {},
options: { expiresIn?: string | number; label?: string | null } = {},
): Promise<string> {
if (!actor.user)
throw new HttpError(403, 'Actor must be a user', {
@@ -1457,6 +1476,20 @@ export class AuthService extends PuterService {
);
}
// Full-API-access sentinel: a token that may do anything its issuing
// user can do via the API (resolved against the issuer at check time —
// see PermissionService.#scanAccessToken). Only a plain user actor may
// mint one; an app-under-user actor must not be able to escalate the
// scoped access it was granted into blanket account-wide access.
const wantsFullAccess = permissions.some(
([p]) => p === FULL_API_ACCESS,
);
if (wantsFullAccess && actor.app) {
throw new HttpError(403, 'Apps may not mint full-access tokens', {
legacyCode: 'forbidden',
});
}
// Permission-subset enforcement: an access token can only carry
// permissions the issuer itself holds. Without this, an
// app-under-user actor (third-party app authorized by the user)
@@ -1465,12 +1498,19 @@ export class AuthService extends PuterService {
// returned verbatim at check-time, with no re-validation against
// the authorizer. `checkMany` is one pipelined MGET against the
// per-actor permission cache so the cost is small even for
// many-permission mints.
// many-permission mints. The full-access sentinel is excluded — it
// isn't a real permission the issuer "holds"; its gate is the
// user-actor check above.
const requestedPerms = [
...new Set(
permissions
.map(([p]) => p)
.filter((p): p is string => typeof p === 'string' && !!p),
.filter(
(p): p is string =>
typeof p === 'string' &&
!!p &&
p !== FULL_API_ACCESS,
),
),
];
if (requestedPerms.length > 0) {
@@ -1509,6 +1549,9 @@ export class AuthService extends PuterService {
actor.user.id as number,
{
kind: 'access_token',
// User-facing name shown (and editable) in the manage-sessions
// UI. Trimmed/clamped by the caller; null when unnamed.
label: options.label ?? null,
parent_session_id,
expires_at: expiresAt,
auth_id,
@@ -1530,6 +1573,14 @@ export class AuthService extends PuterService {
if (actor.app) {
jwtPayload.app_uid = actor.app.uid;
}
// Full-access is carried as a signed claim (not a stored permission
// row): it's the single source of truth read at auth time into
// `actor.accessToken.fullAccess`, which both `requireNonAccessTokenGate`
// and the permission scan consult. The `actor.app` block above already
// rejected app-issued full-access mints.
if (wantsFullAccess) {
jwtPayload.full_access = true;
}
// jsonwebtoken's SignOptions.expiresIn is typed as `number |
// ${number}${unit}` (template literal), so a plain string can't
@@ -1539,7 +1590,11 @@ export class AuthService extends PuterService {
const jwt = this.services.token.sign(
'auth',
jwtPayload,
options as { expiresIn?: number },
// Only `expiresIn` is a valid jsonwebtoken sign option; `label` is
// ours (stored on the session row above), so don't forward it.
options.expiresIn !== undefined
? { expiresIn: options.expiresIn as number }
: {},
);
// Store each permission grant
@@ -1550,6 +1605,9 @@ export class AuthService extends PuterService {
};
for (const spec of permissions) {
const [permission, extra] = spec;
// The full-access sentinel is not a real grant — it lives in the
// signed `full_access` claim, not `access_token_permissions`.
if (permission === FULL_API_ACCESS) continue;
await (db.clients?.db ?? this.clients.db).write(
'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `authorizer_app_id`, `permission`, `extra`) VALUES (?, ?, ?, ?, ?)',
[
@@ -1836,6 +1894,12 @@ export class AuthService extends PuterService {
uid: decoded.token_uid,
issuer: authorizer,
authorized: null,
// Honor the signed full-access claim only for user-issued
// tokens. App-issued tokens (`app_uid` present) can never be
// full-access — mirrors the mint-time block — so even a
// claim on one is ignored here.
fullAccess:
!decoded.app_uid && decoded.full_access === true,
},
},
};
+42 -18
View File
@@ -26,6 +26,7 @@ import { generate_identifier } from '../../util/identifier.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { Context } from '../../core';
import crypto from 'node:crypto';
import { verifyOidcIdToken, type JwksCacheEntry } from './oidcIdToken';
const GOOGLE_DISCOVERY_URL =
'https://accounts.google.com/.well-known/openid-configuration';
@@ -48,6 +49,11 @@ interface ProviderConfig {
userinfo_endpoint: string;
scopes: string;
response_mode?: string;
// From OIDC discovery — used to verify the id_token signature when there
// is no userinfo endpoint (e.g. Apple). Absent for custom providers
// configured without discovery (those use the userinfo path instead).
jwks_uri?: string;
issuer?: string;
}
interface OIDCUserInfo {
@@ -71,6 +77,7 @@ export class OIDCService extends PuterService {
declare protected services: LayerInstances<typeof puterServices>;
#discoveryCache: Map<string, Record<string, string>> = new Map();
#jwksCache: Map<string, JwksCacheEntry> = new Map();
#providers: Record<string, Record<string, string>> = {};
override onServerStart(): void {
@@ -109,6 +116,8 @@ export class OIDCService extends PuterService {
userinfo_endpoint: '',
scopes: raw.scopes ?? APPLE_SCOPES,
response_mode: 'form_post',
jwks_uri: discovery.jwks_uri,
issuer: discovery.issuer,
};
}
@@ -133,6 +142,8 @@ export class OIDCService extends PuterService {
token_endpoint: discovery.token_endpoint,
userinfo_endpoint: discovery.userinfo_endpoint,
scopes: raw.scopes ?? MICROSOFT_SCOPES,
jwks_uri: discovery.jwks_uri,
issuer: discovery.issuer,
};
}
@@ -149,6 +160,8 @@ export class OIDCService extends PuterService {
token_endpoint: discovery.token_endpoint,
userinfo_endpoint: discovery.userinfo_endpoint,
scopes: raw.scopes ?? GOOGLE_SCOPES,
jwks_uri: discovery.jwks_uri,
issuer: discovery.issuer,
};
}
@@ -296,9 +309,10 @@ export class OIDCService extends PuterService {
return (await res.json()) as OIDCUserInfo;
}
// No userinfo endpoint — decode claims from the id_token (e.g. Apple).
if (idToken) {
return this.#decodeIdToken(idToken);
// No userinfo endpoint — verify the id_token signature against the
// provider's JWKS and read claims from it (e.g. Apple).
if (idToken && config) {
return this.#verifyIdToken(idToken, config);
}
return null;
@@ -642,20 +656,30 @@ export class OIDCService extends PuterService {
return `${signingInput}.${signature.toString('base64url')}`;
}
#decodeIdToken(idToken: string): OIDCUserInfo | null {
try {
const parts = idToken.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString(),
);
return {
sub: payload.sub,
email: payload.email,
email_verified: payload.email_verified,
};
} catch {
return null;
}
/**
* Verify an id_token against the provider's JWKS and return its claims.
* Used for providers without a userinfo endpoint (e.g. Apple). Delegates
* to the standalone verifier, passing this service's JWKS cache so keys
* are reused across calls. See {@link verifyOidcIdToken} for semantics.
*/
async #verifyIdToken(
idToken: string,
config: ProviderConfig,
): Promise<OIDCUserInfo | null> {
const claims = await verifyOidcIdToken(
idToken,
{
jwksUri: config.jwks_uri,
issuer: config.issuer,
audience: config.client_id,
},
{ cache: this.#jwksCache },
);
if (!claims) return null;
return {
sub: claims.sub,
email: claims.email,
email_verified: claims.email_verified,
};
}
}
@@ -65,6 +65,74 @@ describe('TokenService.onServerStart', () => {
const svc = new TokenService(config, clients, stores, services);
expect(() => svc.onServerStart()).toThrow(/jwt_secret_v2/);
});
it('refuses to start outside dev with the placeholder secrets from config.default.json', () => {
const config = {
env: 'prod',
jwt_secret: V1_SECRET,
jwt_secret_v2: 'dev-jwt-secret-v2-change-me',
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[3],
];
const svc = new TokenService(config, clients, stores, services);
expect(() => svc.onServerStart()).toThrow(/placeholder/);
});
it('refuses to start outside dev with the placeholder url_signature_secret', () => {
const config = {
env: 'prod',
jwt_secret: 'a-real-v1-secret',
jwt_secret_v2: 'a-real-v2-secret',
url_signature_secret: 'dev-url-signature-secret-change-me',
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[3],
];
const svc = new TokenService(config, clients, stores, services);
expect(() => svc.onServerStart()).toThrow(
/url_signature_secret.*placeholder/,
);
});
it('allows a real non-dev secret that merely contains "change-me"', () => {
// The guard matches the exact shipped placeholders, not the
// "change-me" substring, so an operator's high-entropy secret that
// happens to include those characters must not be refused.
const config = {
env: 'prod',
jwt_secret: 'a-real-v1-secret',
jwt_secret_v2: 'kf83-change-me-not-the-placeholder-9af2',
url_signature_secret: 'another-real-secret',
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[3],
];
const svc = new TokenService(config, clients, stores, services);
expect(() => svc.onServerStart()).not.toThrow();
});
it('allows the placeholder secrets in dev', () => {
const config = {
env: 'dev',
jwt_secret: 'dev-jwt-secret-change-me',
jwt_secret_v2: 'dev-jwt-secret-v2-change-me',
url_signature_secret: 'dev-url-signature-secret-change-me',
} as ConstructorParameters<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[3],
];
const svc = new TokenService(config, clients, stores, services);
expect(() => svc.onServerStart()).not.toThrow();
});
});
describe('TokenService.sign', () => {
@@ -235,3 +303,45 @@ describe('TokenService.verify — v1 fallback', () => {
expect(payload).toMatchObject({ type: 'session', legacy: true });
});
});
describe('TokenService.verify — algorithm pinning', () => {
// jsonwebtoken has always defaulted to HS256 for string secrets, so
// every token ever minted by Puter (v1 and v2) is HS256 — pinning
// `algorithms: ['HS256']` must not invalidate any existing token.
it('existing tokens keep working: minted v2 and default-signed v1 tokens are HS256 and verify', () => {
const svc = createTokenService();
const v2 = svc.sign('auth', {
type: 'session',
user_uid: 'uu',
session_uid: 'su',
auth_id: 'ai',
});
expect(jwt.decode(v2, { complete: true })).toMatchObject({
header: { alg: 'HS256' },
});
expect(() => svc.verify('auth', v2)).not.toThrow();
const v1 = mintV1Token({ t: 's', uu: 'user-uuid' });
expect(jwt.decode(v1, { complete: true })).toMatchObject({
header: { alg: 'HS256' },
});
expect(svc.verify<Record<string, unknown>>('auth', v1)).toMatchObject({
legacy: true,
});
});
it('rejects a v2-routed token signed with a non-HS256 algorithm, even with the right secret', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, V2_SECRET, {
algorithm: 'HS384',
keyid: 'v2',
});
expect(() => svc.verify('auth', token)).toThrow(/algorithm/);
});
it('rejects a v1-routed token signed with a non-HS256 algorithm, even with the right secret', () => {
const svc = createTokenService();
const token = jwt.sign({ t: 's' }, V1_SECRET, { algorithm: 'HS512' });
expect(() => svc.verify('auth', token)).toThrow(/algorithm/);
});
});
+39
View File
@@ -179,6 +179,16 @@ export class V1TokensDisabledError extends Error {
// -- TokenService ----------------------------------------------------
// The exact secret values shipped in config.default.json. Matched exactly
// (not by substring) so the boot guard refuses only these known-insecure
// defaults and never a legitimate operator secret that happens to contain
// "change-me".
const SHIPPED_PLACEHOLDER_SECRETS = new Set([
'dev-jwt-secret-change-me',
'dev-jwt-secret-v2-change-me',
'dev-url-signature-secret-change-me',
]);
export class TokenService extends PuterService {
#secretV2: string = '';
#secretLegacy: string = '';
@@ -191,6 +201,32 @@ export class TokenService extends PuterService {
'TokenService requires `jwt_secret_v2` in config — v2 signing cannot proceed without it',
);
}
// The dev placeholders ship in config.default.json (a public repo) and
// survive a deep-merge when an override config omits them — anyone who
// knows the placeholder can forge with that secret. For the JWT secrets
// that means forging a session token for any user; for
// `url_signature_secret` it means forging file read/write capability
// URLs for any file uid. Refuse to boot a non-dev deployment on any
// placeholder secret. (`url_signature_secret` is guarded here, with the
// other shipped placeholder secrets, even though it's consumed
// elsewhere — this is the one hook guaranteed to run before any request.)
if (this.config.env !== 'dev') {
for (const [name, value] of [
['jwt_secret_v2', secretV2],
['jwt_secret', this.config.jwt_secret ?? ''],
[
'url_signature_secret',
this.config.url_signature_secret ?? '',
],
] as const) {
if (SHIPPED_PLACEHOLDER_SECRETS.has(value)) {
throw new Error(
`\`${name}\` is still the dev placeholder from config.default.json; ` +
'set a real secret before running with env != "dev"',
);
}
}
}
this.#secretV2 = secretV2;
// Legacy secret is optional in fresh installs (no v1 tokens to verify),
// but every existing deployment carries one. Don't fail boot if it's
@@ -241,6 +277,8 @@ export class TokenService extends PuterService {
if (kid === 'v2') {
const payload = jwt.verify(token, this.#secretV2, {
clockTolerance: CLOCK_TOLERANCE_SECONDS,
// Secrets are symmetric; never accept asymmetric algs here.
algorithms: ['HS256'],
}) as Record<string, unknown>;
return this.#decompressPayload(context, payload) as unknown as T;
}
@@ -269,6 +307,7 @@ export class TokenService extends PuterService {
}
const payload = jwt.verify(token, this.#secretLegacy, {
clockTolerance: CLOCK_TOLERANCE_SECONDS,
algorithms: ['HS256'],
}) as Record<string, unknown>;
const decompressed = this.#decompressPayload(
context,
@@ -0,0 +1,212 @@
/**
* 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 crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
import { beforeAll, describe, expect, it } from 'vitest';
import {
jwkToPem,
verifyOidcIdToken,
type JWK,
type JwksCacheEntry,
} from './oidcIdToken';
// Real RSA key + matching JWK, generated once. We sign tokens with the private
// key and serve the public JWK over a fake fetch — exercising the actual
// crypto/verify path, mocking only the HTTP boundary (JWKS endpoint).
const JWKS_URI = 'https://provider.example/.well-known/jwks.json';
const ISSUER = 'https://provider.example';
const AUDIENCE = 'client-abc';
const KID = 'key-1';
let privateKey: crypto.KeyObject;
let jwk: JWK;
beforeAll(() => {
const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
privateKey = pair.privateKey;
jwk = {
...(pair.publicKey.export({ format: 'jwk' }) as JWK),
kid: KID,
};
});
const signToken = (
overrides: {
kid?: string;
audience?: string;
issuer?: string;
key?: crypto.KeyObject;
} = {},
): string =>
jwt.sign({ email: '[email protected]', email_verified: true }, overrides.key ?? privateKey, {
algorithm: 'RS256',
keyid: overrides.kid ?? KID,
subject: 'user-123',
audience: overrides.audience ?? AUDIENCE,
issuer: overrides.issuer ?? ISSUER,
});
// Fake fetch that serves a JWKS body and counts how many times it was hit.
const makeFetch = (keys: JWK[], ok = true) => {
let calls = 0;
const fetchImpl = (async () => {
calls++;
return { ok, json: async () => ({ keys }) } as Response;
}) as unknown as typeof fetch;
return { fetchImpl, calls: () => calls };
};
const deps = (
fetchImpl: typeof fetch,
cache: Map<string, JwksCacheEntry> = new Map(),
now?: () => number,
) => ({ cache, fetchImpl, now });
const opts = { jwksUri: JWKS_URI, issuer: ISSUER, audience: AUDIENCE };
describe('jwkToPem', () => {
it('converts a valid JWK to a SPKI PEM', () => {
const pem = jwkToPem(jwk);
expect(pem).toMatch(/-----BEGIN PUBLIC KEY-----/);
});
it('returns null for an unusable JWK', () => {
expect(jwkToPem({ kid: 'x', kty: 'nonsense' })).toBeNull();
});
});
describe('verifyOidcIdToken', () => {
it('returns claims for a correctly signed token', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken(),
opts,
deps(fetchImpl),
);
expect(claims).toEqual({
sub: 'user-123',
email: '[email protected]',
email_verified: true,
});
});
it('rejects a token signed by a different key', async () => {
const other = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
}).privateKey;
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({ key: other }),
opts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('rejects a token with the wrong audience', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({ audience: 'someone-else' }),
opts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('rejects a token with the wrong issuer', async () => {
const { fetchImpl } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken({ issuer: 'https://evil.example' }),
opts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('returns null when no jwks_uri is configured', async () => {
const { fetchImpl, calls } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken(),
{ ...opts, jwksUri: undefined },
deps(fetchImpl),
);
expect(claims).toBeNull();
expect(calls()).toBe(0);
});
it('returns null when the JWKS has no key matching the token kid', async () => {
const { fetchImpl } = makeFetch([{ ...jwk, kid: 'different-kid' }]);
const claims = await verifyOidcIdToken(
signToken(),
opts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('returns null when the JWKS fetch fails', async () => {
const { fetchImpl } = makeFetch([], false);
const claims = await verifyOidcIdToken(
signToken(),
opts,
deps(fetchImpl),
);
expect(claims).toBeNull();
});
it('caches the JWKS across calls (only fetches once)', async () => {
const { fetchImpl, calls } = makeFetch([jwk]);
const cache = new Map<string, JwksCacheEntry>();
await verifyOidcIdToken(signToken(), opts, deps(fetchImpl, cache));
await verifyOidcIdToken(signToken(), opts, deps(fetchImpl, cache));
expect(calls()).toBe(1);
});
it('refetches once when a cached entry lacks the requested kid (key rotation)', async () => {
const cache = new Map<string, JwksCacheEntry>();
// Seed the cache with a stale keyset that doesn't include KID.
cache.set(JWKS_URI, {
keys: [{ ...jwk, kid: 'old-kid' }],
fetchedAt: 1_000,
});
const { fetchImpl, calls } = makeFetch([jwk]);
const claims = await verifyOidcIdToken(
signToken(),
opts,
deps(fetchImpl, cache, () => 2_000),
);
expect(claims).not.toBeNull();
expect(calls()).toBe(1);
});
it('refetches when the cached entry is older than the TTL', async () => {
const cache = new Map<string, JwksCacheEntry>();
cache.set(JWKS_URI, { keys: [jwk], fetchedAt: 0 });
const { fetchImpl, calls } = makeFetch([jwk]);
// now is 2h past fetchedAt -> stale -> refetch.
await verifyOidcIdToken(
signToken(),
opts,
deps(fetchImpl, cache, () => 2 * 60 * 60 * 1000),
);
expect(calls()).toBe(1);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* 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 crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
export interface JWK {
kid?: string;
kty?: string;
use?: string;
[k: string]: unknown;
}
export interface JwksCacheEntry {
keys: JWK[];
fetchedAt: number;
}
/** Claims we read off a verified id_token. */
export interface IdTokenClaims {
sub: string;
email?: string;
email_verified?: boolean;
}
export interface VerifyIdTokenOptions {
/** From OIDC discovery. Verification is impossible without it. */
jwksUri?: string;
/** Expected `iss` claim (from discovery). */
issuer?: string;
/** Expected `aud` claim — the provider client id. */
audience: string;
}
export interface VerifyIdTokenDeps {
/** Caller-owned JWKS cache (keyed by jwks_uri), so keys survive calls. */
cache: Map<string, JwksCacheEntry>;
/** Injectable for tests; defaults to global `fetch`. */
fetchImpl?: typeof fetch;
/** Injectable clock for tests; defaults to `Date.now`. */
now?: () => number;
}
const ONE_HOUR_MS = 60 * 60 * 1000;
/** Convert a JWK public key to a SPKI PEM, or null if it can't be imported. */
export const jwkToPem = (jwk: JWK): string | null => {
try {
const keyObject = crypto.createPublicKey({
key: jwk as crypto.JsonWebKey,
format: 'jwk',
});
return keyObject.export({ type: 'spki', format: 'pem' }).toString();
} catch (e) {
console.warn('[oidc] failed to import JWKS key', e);
return null;
}
};
const fetchJwks = async (
jwksUri: string,
fetchImpl: typeof fetch,
now: () => number,
): Promise<JwksCacheEntry | null> => {
try {
const res = await fetchImpl(jwksUri);
if (!res.ok) return null;
const data = (await res.json()) as { keys?: JWK[] };
if (!Array.isArray(data.keys)) return null;
return { keys: data.keys, fetchedAt: now() };
} catch (e) {
console.warn('[oidc] JWKS fetch failed', e);
return null;
}
};
/**
* Resolve a JWKS key by `kid` to a PEM public key. Responses are cached for an
* hour; a cache miss on an unknown kid forces one refresh to handle provider
* key rotation.
*/
export const getSigningKey = async (
jwksUri: string,
kid: string,
deps: VerifyIdTokenDeps,
): Promise<string | null> => {
const fetchImpl = deps.fetchImpl ?? fetch;
const now = deps.now ?? Date.now;
let entry = deps.cache.get(jwksUri);
const findKey = () => entry?.keys.find((k) => k.kid === kid) ?? null;
let jwk = entry && now() - entry.fetchedAt < ONE_HOUR_MS ? findKey() : null;
if (!jwk) {
const fetched = await fetchJwks(jwksUri, fetchImpl, now);
if (!fetched) return null;
entry = fetched;
deps.cache.set(jwksUri, entry);
jwk = findKey();
}
if (!jwk) return null;
return jwkToPem(jwk);
};
/**
* Verify an id_token's signature against the provider's JWKS and return its
* claims. Returns null if there's no jwks_uri, the signing key can't be found,
* or signature/claim verification fails.
*
* Used for providers without a userinfo endpoint (e.g. Apple). The token
* already arrives directly from the provider's token endpoint over TLS, so
* this is defense-in-depth — but verifying the signature is the correct
* behaviour rather than trusting an unverified base64 payload.
*/
export const verifyOidcIdToken = async (
idToken: string,
opts: VerifyIdTokenOptions,
deps: VerifyIdTokenDeps,
): Promise<IdTokenClaims | null> => {
if (!opts.jwksUri) {
console.warn(
'[oidc] id_token cannot be verified: provider has no jwks_uri',
);
return null;
}
const decoded = jwt.decode(idToken, { complete: true });
const kid =
decoded && typeof decoded === 'object'
? (decoded.header?.kid ?? null)
: null;
if (!kid) return null;
const pem = await getSigningKey(opts.jwksUri, kid, deps);
if (!pem) return null;
try {
const payload = jwt.verify(idToken, pem, {
algorithms: ['RS256', 'ES256'],
audience: opts.audience,
issuer: opts.issuer,
}) as Record<string, unknown>;
return {
sub: payload.sub as string,
email: payload.email as string | undefined,
email_verified: payload.email_verified as boolean | undefined,
};
} catch (e) {
console.warn('[oidc] id_token verification failed', e);
return null;
}
};
+6
View File
@@ -87,6 +87,12 @@ export interface AccessTokenPayload extends TokenPayloadBase {
token_uid: string;
user_uid: string;
app_uid?: string;
/**
* Full-API-access ("personal access token") marker. Only ever set on
* user-issued tokens (never app-issued). Drives `actor.accessToken
* .fullAccess` — see ActorAccessToken in core/actor.ts.
*/
full_access?: boolean;
}
export type AnyTokenPayload =
+83
View File
@@ -2133,6 +2133,46 @@ export class FSService extends PuterService {
);
}
// The size recorded so far is the client-declared value from the
// start-write request. On a signed (direct-to-S3) upload the
// client could declare `1` and PUT gigabytes — the presigned URL
// doesn't bound the body — so reconcile against the object's true
// size before persisting. Without this, storage accounting is
// understated permanently (quota is SUM(size)) and the free-tier
// limit is bypassable. Mirrors the server-proxied /write path.
const reconcileBucket =
session.bucket ?? createInput.bucket ?? this.#resolveBucket();
const reconcileRegion =
session.bucketRegion ??
createInput.bucketRegion ??
this.#resolveBucketRegion();
let trueSize: number | null = null;
try {
trueSize = await this.stores.s3Object.headObjectSize(
reconcileBucket,
session.objectKey,
reconcileRegion,
);
} catch {
// HEAD failed (e.g. object never uploaded) — leave the
// declared size; don't block completion on a metadata read.
trueSize = null;
}
if (typeof trueSize === 'number' && trueSize >= 0) {
// Record the true size only — do not re-assert the quota here.
// The bytes are already in S3, so a completion-time reject
// can't reclaim them; it only false-rejects (the start-check
// may have used a higher storageAllowanceMax override that
// isn't persisted in the session) and deletes within-quota
// uploads. Recording real bytes is what closes the bypass:
// the user's SUM(size) becomes accurate so their next signed
// -write start-check (#assertStorageAllowance via
// getUserStorageAllowance) blocks them. The residual is a
// single in-flight upload over quota — the same bounded
// check-then-act window the start-check already has.
createInput.size = trueSize;
}
const fsEntry = await this.stores.fsEntry.completePendingEntry(
session.sessionId,
createInput,
@@ -2313,6 +2353,49 @@ export class FSService extends PuterService {
throw new Error('Failed to complete multipart upload');
}
// Reconcile client-declared sizes against the true uploaded object
// sizes before persisting — see completeUrlWrite for the rationale.
// Without this the batch endpoint is a parallel bypass of the same
// storage-quota check.
const reconcileBucketRegion = (
item: (typeof completionItems)[number],
) => ({
bucket:
item.session.bucket ??
item.finalData.bucket ??
this.#resolveBucket(),
region:
item.session.bucketRegion ??
item.finalData.bucketRegion ??
this.#resolveBucketRegion(),
});
const headSizes = await Promise.all(
completionItems.map(async (item) => {
const { bucket, region } = reconcileBucketRegion(item);
try {
return await this.stores.s3Object.headObjectSize(
bucket,
item.session.objectKey,
region,
);
} catch {
return null;
}
}),
);
// Record the true sizes only — do not re-assert the quota here. See
// completeUrlWrite: the bytes are already in S3 so a completion-time
// reject can't reclaim them, and per-item deletes would destroy the
// bytes of correctly-declared, within-quota siblings in the batch.
// Recording real sizes keeps SUM(size) accurate so the next
// signed-write start-check blocks an over-quota user.
for (let index = 0; index < completionItems.length; index++) {
const item = completionItems[index];
const trueSize = headSizes[index];
if (typeof trueSize !== 'number' || trueSize < 0) continue;
item.finalData.size = trueSize;
}
const completedEntries =
await this.stores.fsEntry.batchCompletePendingEntries(
completionItems.map((item) => ({
@@ -17,16 +17,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { kv } from '../../util/kvSingleton.js';
import { PermissionService } from './PermissionService.js';
function createPermissionService(): PermissionService {
const permissionStore = {
getCacheGeneration: async () => 0,
getMultiCheckCache: async () => new Map<string, boolean>(),
setMultiCheckCache: async () => undefined,
};
@@ -622,16 +624,381 @@ describe('PermissionService (integration)', () => {
});
});
describe('invalidatePermissionScanCacheForAppUnderUser', () => {
it('runs to completion against the real permission store', async () => {
const { user } = await makeUserActor();
await expect(
permService.invalidatePermissionScanCacheForAppUnderUser(
user.uuid,
`app-${uuidv4()}`,
'service:foo:ii:read',
describe('cache-generation invalidation on grant/revoke', () => {
// The grant/revoke paths bump the holder's per-actor cache
// generation so a change takes effect on the very next check
// rather than after the scan-cache TTL. These exercise the real
// Redis-backed (ioredis-mock) cache via the live permission store.
const grantManage = async (
issuer: { id: number },
permission: string,
) => {
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
};
it('revoke is visible immediately — a cached "granted" reading is not served', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target, actor: targetActor } =
await makeUserActor();
const permission = `service:revoke-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
).resolves.toBeUndefined();
);
// Prime the cache: the holder sees the grant (this writes the
// scan/check cache under the current generation).
expect(await permService.check(targetActor, permission)).toBe(
true,
);
await runWithContext({ actor: issuerActor }, () =>
permService.revokeUserUserPermission(
issuerActor,
target.username,
permission,
),
);
// Without the generation bump this would still read `true` from
// the primed cache for up to the TTL.
expect(await permService.check(targetActor, permission)).toBe(
false,
);
});
it('caches the generation in-process so repeat reads skip Redis, and a bump updates the local copy at once', async () => {
const { user } = await makeUserActor();
const aUid = `user:${user.uuid}`;
const localKey = `permgen-local:${aUid}`;
// Cold: nothing cached locally yet.
expect(kv.get(localKey)).toBeUndefined();
// First read populates the in-process cache (avoids a Redis GET
// on every subsequent permission check for this actor).
const g = await server.stores.permission.getCacheGeneration(aUid);
expect(kv.get(localKey)).toBe(g);
// A bump makes this node consistent immediately — no waiting for
// the local TTL — so single-node revocation is instant.
await server.stores.permission.bumpCacheGeneration(aUid);
expect(kv.get(localKey)).toBe(g + 1);
expect(await server.stores.permission.getCacheGeneration(aUid)).toBe(
g + 1,
);
});
it('grant is visible immediately — a cached "denied" reading is not served', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target, actor: targetActor } =
await makeUserActor();
const permission = `service:grant-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// Prime a "denied" reading into the cache.
expect(await permService.check(targetActor, permission)).toBe(
false,
);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
expect(await permService.check(targetActor, permission)).toBe(
true,
);
});
});
describe('derived-actor cache invalidation (app-under-user)', () => {
// An app-under-user actor's reading embeds its user's reading, and
// its cache keys fold in the user's generation counter — so a
// user-level grant/revoke must take effect for the user's app
// actors on their very next check, not after the scan-cache TTL.
const grantManage = async (
issuer: { id: number },
permission: string,
) => {
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
};
const makeApp = async (ownerUserId: number) =>
(server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>)(
{
name: `dac-${uuidv4()}`,
title: 'Derived-actor cache app',
index_url: `https://dac-${uuidv4()}.test/`,
},
{ ownerUserId },
);
it('a user-level revoke is visible immediately to the user\'s app actors', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target, actor: targetActor } =
await makeUserActor();
const app = await makeApp(target.id);
const permission = `service:app-revoke-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
// The user lets the app act with this permission, so the app
// actor resolves it through the user's own reading.
await runWithContext({ actor: targetActor }, () =>
permService.grantUserAppPermission(
targetActor,
app.uid,
permission,
),
);
const appActor = {
user: targetActor.user,
app: { id: app.id, uid: app.uid },
} as unknown as Actor;
// Prime the app actor's cache with a "granted" reading.
expect(await permService.check(appActor, permission)).toBe(true);
await runWithContext({ actor: issuerActor }, () =>
permService.revokeUserUserPermission(
issuerActor,
target.username,
permission,
),
);
// Without the user generation folded into the app actor's
// cache keys this would still read `true` for up to the TTL.
expect(await permService.check(appActor, permission)).toBe(false);
});
it('a user-level grant busts an app actor\'s cached denial immediately', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target, actor: targetActor } =
await makeUserActor();
const app = await makeApp(target.id);
const permission = `service:app-grant-now-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// App is allowed to act with the permission, but the user does
// not hold it yet — primes a "denied" reading for the app actor.
await runWithContext({ actor: targetActor }, () =>
permService.grantUserAppPermission(
targetActor,
app.uid,
permission,
),
);
const appActor = {
user: targetActor.user,
app: { id: app.id, uid: app.uid },
} as unknown as Actor;
expect(await permService.check(appActor, permission)).toBe(false);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
expect(await permService.check(appActor, permission)).toBe(true);
});
});
describe('revoke durability (flat/linked consistency)', () => {
const grantManage = async (
issuer: { id: number },
permission: string,
) => {
await server.stores.permission.setFlatUserPerm(
issuer.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
};
it('revokeUserUserPermission deletes the linked SQL row before resolving', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target } = await makeUserActor();
const permission = `service:rvk-sync-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
await runWithContext({ actor: issuerActor }, () =>
permService.revokeUserUserPermission(
issuerActor,
target.username,
permission,
),
);
// The linked row must be gone the moment the revoke resolves —
// a fire-and-forget delete could lose the race against the
// post-bump rescan, which would re-warm the flat view from the
// surviving SQL row and resurrect the grant.
const rows = await server.stores.permission.readLinkedUserUserPerms(
target.id,
[permission],
);
expect(rows).toHaveLength(0);
});
it('revokeUserUserPermission surfaces a failed SQL delete instead of swallowing it', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target } = await makeUserActor();
const permission = `service:rvk-fail-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
const spy = vi
.spyOn(server.stores.permission, 'deleteUserUserPermByHolder')
.mockRejectedValue(new Error('simulated db failure'));
try {
await expect(
runWithContext({ actor: issuerActor }, () =>
permService.revokeUserUserPermission(
issuerActor,
target.username,
permission,
),
),
).rejects.toThrow('simulated db failure');
} finally {
spy.mockRestore();
}
// Retry once the store works again — the revoke completes.
await runWithContext({ actor: issuerActor }, () =>
permService.revokeUserUserPermission(
issuerActor,
target.username,
permission,
),
);
});
it('scan-path warms of the flat view carry an expiry (grants are permanent)', async () => {
const { user: issuer, actor: issuerActor } =
await makeUserActor();
const { user: target, actor: targetActor } =
await makeUserActor();
const permission = `service:warm-ttl-${uuidv4()}:ii:read`;
await grantManage(issuer, permission);
// The linked (SQL) path is a delegation chain: it only grants
// if the issuer holds the permission themselves. Give the
// issuer a terminal flat grant so the fallback below resolves.
await server.stores.permission.setFlatUserPerm(
issuer.id,
permission,
{
permission,
deleted: false,
issuer_user_id: issuer.id,
} as never,
);
await runWithContext({ actor: issuerActor }, () =>
permService.grantUserUserPermission(
issuerActor,
target.username,
permission,
),
);
// The grant's linked-row upsert is fire-and-forget — wait for
// it so the linked fallback below has something to find.
await vi.waitFor(async () => {
const rows =
await server.stores.permission.readLinkedUserUserPerms(
target.id,
[permission],
);
expect(rows.length).toBeGreaterThan(0);
});
// Drop the flat entry so the next check takes the linked SQL
// fallback and re-warms the flat view.
await server.stores.permission.delFlatUserPerm(
target.id,
permission,
);
const spy = vi.spyOn(server.stores.permission, 'setFlatUserPerm');
try {
expect(await permService.check(targetActor, permission)).toBe(
true,
);
// The warm is fire-and-forget; wait for it to land.
await vi.waitFor(() => {
const warmCall = spy.mock.calls.find(
(c) => c[1] === permission,
);
expect(warmCall).toBeDefined();
// Derived warms must self-expire so one that races a
// concurrent revoke cannot persist indefinitely.
expect(warmCall![3]?.expireAt).toBeGreaterThan(
Math.floor(Date.now() / 1000),
);
});
} finally {
spy.mockRestore();
}
});
});
});
@@ -23,6 +23,7 @@ import { Context } from '../../core/context';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterService } from '../types';
import {
FLAT_PERM_WARM_TTL_SECONDS,
MANAGE_PERM_PREFIX,
PERMISSION_SCAN_CACHE_TTL_SECONDS,
} from './consts';
@@ -215,9 +216,11 @@ export class PermissionService extends PuterService {
// -- Cache pass: one pipelined MGET --
const aUid = actorUid(actor);
const generation = await this.#cacheGenerationTag(actor);
const cached = await this.stores.permission.getMultiCheckCache(
aUid,
dedup,
generation,
);
const missing: string[] = [];
for (const p of dedup) {
@@ -251,8 +254,13 @@ export class PermissionService extends PuterService {
// Backfill cache (best-effort, fire-and-forget would also be
// fine — keeping it awaited so callers see deterministic state
// in tests).
await this.stores.permission.setMultiCheckCache(aUid, writeBack);
// in tests). Use the same generation read above so a concurrent
// bump doesn't get masked by this write.
await this.stores.permission.setMultiCheckCache(
aUid,
writeBack,
generation,
);
return out;
}
@@ -286,9 +294,16 @@ export class PermissionService extends PuterService {
const workingState: ScanState = state ?? { antiCycleActors: [actor] };
// -- Redis scan cache --
// The per-actor cache generation is folded into the key so a
// grant/revoke bump orphans this actor's cached readings at once.
// For derived actors the tag combines every relevant counter, so a
// user-level bump also orphans that user's app/token actors.
const aUid = actorUid(actor);
const generation = await this.#cacheGenerationTag(actor);
const cacheKey = this.stores.permission.buildScanCacheKey(
actorUid(actor),
aUid,
options,
generation,
);
if (!scanOptions.noCache) {
const cached = await this.stores.permission.getScanCache(cacheKey);
@@ -431,6 +446,29 @@ export class PermissionService extends PuterService {
): Promise<void> {
if (!actor.accessToken) return;
const issuerActor = actor.accessToken.issuer;
// Full-API-access token: it may do anything its issuing user can do.
// Resolve every requested option directly against the issuer (the
// owner FS implicator, group/service grants, and user-to-user grants
// all apply to the user actor), with no per-permission grant row
// required. The marker is the signed `full_access` claim surfaced on
// the actor (single source of truth, set only for user-issued tokens).
// Account-management endpoints stay closed because they gate on actor
// type (requireUserActor / session cookie), not permissions.
if (actor.accessToken.fullAccess) {
for (const permission of options) {
const issuerReading = await this.scan(issuerActor, permission);
reading.push({
$: 'path',
via: 'access-token',
has_terminal: readingHasTerminal(issuerReading),
permission,
reading: issuerReading,
});
}
return;
}
for (const permission of options) {
const hasTokenPerm =
await this.stores.permission.hasAccessTokenPerm(
@@ -726,24 +764,43 @@ export class PermissionService extends PuterService {
const linkedReading = await linkedPromise;
const flatOptions = PermissionUtil.readingToOptions(linkedReading);
// Warm flat KV cache for future hits (fire-and-forget, don't block result)
// Warm flat KV cache for future hits (fire-and-forget, don't block
// result). Warms expire: they are derived from the SQL traversal
// above, and a warm whose KV write lands after a concurrent
// revoke's flat delete would otherwise re-materialize the revoked
// grant permanently. The expiry bounds that to the warm TTL — the
// next scan re-derives from SQL, which the revoke deletes
// synchronously. (Grant-path flat writes are authoritative and
// carry no expiry.)
const warmExpireAt =
Math.floor(Date.now() / 1000) + FLAT_PERM_WARM_TTL_SECONDS;
for (const opt of flatOptions) {
if (!opt.permission) continue;
const data = Array.isArray(opt.data) ? opt.data : [opt.data];
const issuerUserId = (data[0] as { issuer_user_id?: number })
?.issuer_user_id;
this.stores.permission
.setFlatUserPerm(actor.user.id, opt.permission, {
permission: opt.permission,
issuer_user_id: issuerUserId,
data,
})
.setFlatUserPerm(
actor.user.id,
opt.permission,
{
permission: opt.permission,
issuer_user_id: issuerUserId,
data,
},
{ expireAt: warmExpireAt },
)
.catch(() => {
/* swallow — this is a cache warm */
});
}
return flatReading;
// Return the traversal result itself — not the (empty) flat
// reading — so the fallback grants on the check that took it
// rather than only after the warm lands. Returning the empty flat
// reading here would cache a false "denied" for the TTL every
// time a warm expires.
return linkedReading;
}
async #flatValidateUserPerms(
@@ -884,6 +941,9 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'granted via PermissionService',
})
.catch(() => {});
// Bust any cached "denied" reading so the grant is live immediately.
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
}
async revokeUserUserPermission(
@@ -911,9 +971,17 @@ export class PermissionService extends PuterService {
const issuerId = actor.user.id;
await this.stores.permission.delFlatUserPerm(user.id, permission);
this.stores.permission
.deleteUserUserPermByHolder(user.id, permission)
.catch(() => {});
// Awaited (unlike the grant-path upsert): the generation bump below
// guarantees the holder's very next check re-derives from SQL, so a
// fire-and-forget delete here could lose the race and let that scan
// resurrect the revoked grant into the flat view. If this fails the
// caller gets the error — the permission is then still effectively
// granted (flat falls back to the surviving SQL row), which is the
// consistent, retryable outcome.
await this.stores.permission.deleteUserUserPermByHolder(
user.id,
permission,
);
this.stores.permission
.auditUserUserPerm({
holder_user_id: user.id,
@@ -923,6 +991,9 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'revoked via PermissionService',
})
.catch(() => {});
// The holder loses access on their next check, not after the TTL.
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
}
async grantUserAppPermission(
@@ -979,12 +1050,9 @@ export class PermissionService extends PuterService {
})
.catch(() => {});
// Invalidate app-under-user scan cache so the grant takes effect immediately
await this.invalidatePermissionScanCacheForAppUnderUser(
actor.user.uuid!,
app.uid,
permission,
);
// Bump the app-under-user cache generation so the grant takes
// effect on the next check rather than after the cache TTL.
await this.#bumpAppUnderUserCacheGeneration(actor.user.uuid!, app.uid);
}
async revokeUserAppPermission(
@@ -1022,6 +1090,13 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'revoked via PermissionService',
})
.catch(() => {});
if (actor.user.uuid) {
await this.#bumpAppUnderUserCacheGeneration(
actor.user.uuid,
app.uid,
);
}
}
async revokeUserAppAll(
@@ -1053,6 +1128,13 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'revoked all via PermissionService',
})
.catch(() => {});
if (actor.user.uuid) {
await this.#bumpAppUnderUserCacheGeneration(
actor.user.uuid,
app.uid,
);
}
}
async grantDevAppPermission(
@@ -1194,6 +1276,8 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'granted via PermissionService',
})
.catch(() => {});
await this.#bumpGroupMembersCacheGeneration(group.uid);
}
async revokeUserGroupPermission(
@@ -1222,6 +1306,8 @@ export class PermissionService extends PuterService {
reason: meta.reason ?? 'revoked via PermissionService',
})
.catch(() => {});
await this.#bumpGroupMembersCacheGeneration(group.uid);
}
// -- Issuer queries (share discovery et al) -----------------------
@@ -1311,17 +1397,103 @@ export class PermissionService extends PuterService {
}
// -- Cache invalidation -------------------------------------------
//
// Grants and revokes bump the affected holder's per-actor cache
// generation (see PermissionStore), which orphans all of that actor's
// cached scan/check readings at once. This is cluster-safe (a single
// INCR, no pattern scan) and makes a grant/revoke take effect on the
// holder's very next check rather than after the cache TTL.
//
// Derived actors fold their user's counter into their cache keys (see
// #cacheGenerationKeys), so a `user:<uuid>` bump also takes immediate
// effect for that user's app-under-user and access-token actors.
// Readings that embed a *different* user's reading (group/dev-app/
// user-user issuer chains) are not generation-linked to that issuer;
// those remain bounded by the scan-cache TTL.
async invalidatePermissionScanCacheForAppUnderUser(
/**
* Generation keys whose counters this actor's cached readings depend
* on. A derived actor (app-under-user, access-token) acts through its
* user — its readings embed that user's reading via the recursive
* issuer scan — so the user's counter is folded into its cache keys.
* A plain user actor depends only on its own counter.
*/
#cacheGenerationKeys(actor: Actor): string[] {
const keys = [actorUid(actor)];
if (actor.accessToken) {
keys.push(...this.#cacheGenerationKeys(actor.accessToken.issuer));
if (actor.accessToken.authorized) {
keys.push(
...this.#cacheGenerationKeys(actor.accessToken.authorized),
);
}
} else if (actor.app && actor.user?.uuid) {
keys.push(`user:${actor.user.uuid}`);
}
return Array.from(new Set(keys));
}
/**
* Cache-generation tag for an actor: the single counter value for a
* plain user actor (key format unchanged: `g<n>`), or the dependent
* counters joined with '.' for derived actors (e.g. `g<own>.<user>`).
* Joined rather than summed so distinct counter states can never
* collide on the same tag.
*/
async #cacheGenerationTag(actor: Actor): Promise<number | string> {
const keys = this.#cacheGenerationKeys(actor);
if (keys.length === 1) {
return this.stores.permission.getCacheGeneration(keys[0]);
}
const gens = await Promise.all(
keys.map((k) => this.stores.permission.getCacheGeneration(k)),
);
return gens.join('.');
}
/** Bump a plain user holder (`user:<uuid>`). */
async #bumpUserCacheGeneration(userUuid: string): Promise<void> {
await this.stores.permission.bumpCacheGeneration(`user:${userUuid}`);
}
/** Bump an app-under-user holder (`app-under-user:<uuid>:<appUid>`). */
async #bumpAppUnderUserCacheGeneration(
userUuid: string,
appUid: string,
permission: string,
): Promise<void> {
const actorUid = `app-under-user:${userUuid}:${appUid}`;
const cacheKey = this.stores.permission.buildScanCacheKey(actorUid, [
permission,
]);
await this.stores.permission.invalidateScanCache(cacheKey);
await this.stores.permission.bumpCacheGeneration(
`app-under-user:${userUuid}:${appUid}`,
);
}
/**
* Bump every current member of a group. Used when a group grant
* changes — each member's readings may have resolved through the group,
* so each member's `user:<uuid>` cache must be orphaned.
*/
async #bumpGroupMembersCacheGeneration(groupUid: string): Promise<void> {
const memberUuids =
await this.stores.group.listMemberUserUuids(groupUid);
await Promise.all(
memberUuids.map((uuid) => this.#bumpUserCacheGeneration(uuid)),
);
}
/**
* Public: bump the permission cache for a set of users by username.
* Used by group add/remove-users — membership changes a user's
* effective permissions, so their cached readings must be orphaned
* (a removed user must lose the group's grants on their next check,
* not after the TTL).
*/
async bumpPermissionCacheForUsernames(usernames: string[]): Promise<void> {
const unique = Array.from(new Set(usernames.filter(Boolean)));
await Promise.all(
unique.map(async (username) => {
const user = await this.stores.user.getByUsername(username);
if (user?.uuid) await this.#bumpUserCacheGeneration(user.uuid);
}),
);
}
// -- Internals ----------------------------------------------------
+52
View File
@@ -29,3 +29,55 @@ export const PERMISSION_FOR_NOTHING_IN_PARTICULAR =
/** TTL (seconds) for redis-cached permission scan readings. */
export const PERMISSION_SCAN_CACHE_TTL_SECONDS = 20;
/**
* TTL (seconds) for the per-actor cache-generation counter. A grant/revoke
* bumps this counter, which is folded into the scan/check cache keys so all
* of that actor's cached readings are orphaned at once (cluster-safe — a
* single-key INCR, no pattern scan). Must be comfortably longer than
* {@link PERMISSION_SCAN_CACHE_TTL_SECONDS} so the counter never lapses back
* to 0 while same-generation cache entries are still live (which would
* revive stale readings). Refreshed on every bump.
*/
export const PERMISSION_CACHE_GENERATION_TTL_SECONDS = 24 * 60 * 60;
/**
* TTL (seconds) for flat user-permission entries written by the scan-path
* cache warm (`validateUserPerms`), as opposed to entries written by an
* explicit grant, which are authoritative and permanent. A warm is derived
* from a SQL traversal, so a warm that races a concurrent revoke (its KV
* write landing after the revoke's flat delete) can re-materialize a
* just-revoked grant. The expiry bounds that failure to this window —
* after it lapses the next scan re-derives from SQL, which the revoke
* deletes synchronously — instead of letting it persist indefinitely.
*/
export const FLAT_PERM_WARM_TTL_SECONDS = 60;
/**
* TTL (seconds) for the per-node in-process cache of the generation
* counter. Permission checks are very hot, so reading the counter from
* Redis on every check would add a round-trip to the hottest path. A tiny
* local cache collapses repeated reads (including the recursive
* issuer-scan and `checkMany` fan-out) to in-memory lookups. The authoritative
* counter still lives in Redis: a bump on any node propagates to the others
* within this window, so this is the cross-node revocation lag (the bumping
* node itself updates its local copy immediately and is consistent at once).
* Keep it short.
*/
export const PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS = 2;
/**
* Sentinel grant for a "full API access" access token: the token may do
* anything its issuing user can do via the API (filesystem, drivers, KV, AI,
* apps, workers — resolved against the issuer at check time in
* `PermissionService.#scanAccessToken`). It does NOT unlock account
* management: access-token actors are still rejected by the
* `requireUserActor` / session-cookie gates that protect change-password,
* change-email, change-username, 2FA, sync-cookie, token minting, etc.
*
* Only a plain user actor may mint a token carrying this grant — never an
* app-under-user actor (which must not be able to escalate to full access).
*
* Chosen over `'*'`, which already appears as an audit-log "revoke all" label.
*/
export const FULL_API_ACCESS = 'full-api-access';
@@ -48,7 +48,10 @@ export class DefaultUserService extends PuterService {
let tmpPassword: string;
if (!user) {
tmpPassword = crypto.randomBytes(4).toString('hex');
// 16 bytes (128 bits) — the old 4 bytes was only 32 bits,
// brute-forceable if the box is reachable before the admin
// rotates the printed bootstrap password.
tmpPassword = crypto.randomBytes(16).toString('hex');
user = await this.#createAdminUser(tmpPassword);
// AppIconService is registered before us, so its own onServerStart
// bailed on its first-boot bootstrap (admin didn't exist yet).
+24
View File
@@ -25,6 +25,7 @@ import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
type S3Client,
UploadPartCommand,
@@ -362,6 +363,29 @@ export class S3ObjectStore extends PuterStore {
);
}
/**
* Return the actual byte size of a stored object via a HEAD request, or
* null if S3 doesn't report a content length. Used to reconcile the
* authoritative size of a direct-to-S3 (signed-URL) upload against the
* client-declared size, which is otherwise untrusted.
*/
async headObjectSize(
bucket: string,
objectKey: string,
region: string,
): Promise<number | null> {
const client = this.#getClientForRegion(region);
const response = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: objectKey,
}),
);
return typeof response.ContentLength === 'number'
? response.ContentLength
: null;
}
async deleteObject(
bucket: string,
objectKey: string,
+16
View File
@@ -142,6 +142,22 @@ export class GroupStore extends PuterStore {
return rows.map((r) => String(r.username));
}
/**
* UUIDs of a group's members — used to bump each member's permission
* cache generation when a group grant changes, so the grant/revoke
* takes effect for members without waiting for the cache TTL.
*/
async listMemberUserUuids(uid: string): Promise<string[]> {
const rows = await this.clients.db.read(
'SELECT u.uuid FROM `user` u ' +
'JOIN (SELECT user_id FROM `jct_user_group` WHERE group_id = ' +
'(SELECT id FROM `group` WHERE uid = ?)) ug ' +
'ON u.id = ug.user_id',
[uid],
);
return rows.map((r) => String(r.uuid));
}
// -- Writes -------------------------------------------------------
/**
@@ -54,12 +54,14 @@ export class NotificationStore extends PuterStore {
} else if (filter === 'acknowledged') {
extraWhere = 'AND `acknowledged` IS NOT NULL';
}
const n = Number(limit);
const safeLimit = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 200;
const rows = await this.clients.db.read(
`SELECT * FROM \`notification\`
WHERE \`user_id\` = ? ${extraWhere}
ORDER BY \`created_at\` DESC
LIMIT ${limit}`,
[userId],
LIMIT ?`,
[userId, safeLimit],
);
return rows.map((r) => this.#normalizeRow(r));
}
+111 -10
View File
@@ -23,8 +23,11 @@ import type { puterStores } from '../index';
import { PermissionUtil } from '../../services/permission/permissionUtil';
import {
PERM_KEY_PREFIX,
PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS,
PERMISSION_CACHE_GENERATION_TTL_SECONDS,
PERMISSION_SCAN_CACHE_TTL_SECONDS,
} from '../../services/permission/consts';
import { kv } from '../../util/kvSingleton';
import type { UserRow } from '../user/UserStore';
// Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters
@@ -124,18 +127,26 @@ export class PermissionStore extends PuterStore {
);
}
/** Write a single flat user-to-user permission entry to KV. */
/**
* Write a single flat user-to-user permission entry to KV.
*
* `opts.expireAt` (epoch seconds) marks the entry as a derived cache
* warm rather than an authoritative grant: warms self-expire so a warm
* that raced a concurrent revoke cannot re-materialize the grant
* indefinitely. Grant-path writes omit it and are permanent.
*/
async setFlatUserPerm(
holderUserId: number,
permission: string,
value: FlatPermValue,
opts: { expireAt?: number } = {},
): Promise<void> {
const key = PermissionUtil.join(
PERM_KEY_PREFIX,
String(holderUserId),
permission,
);
await this.stores.kv.set({ key, value });
await this.stores.kv.set({ key, value, expireAt: opts.expireAt });
}
/** Delete a single flat user-to-user permission entry from KV. */
@@ -553,12 +564,93 @@ export class PermissionStore extends PuterStore {
return rows.map((r) => String(r.permission));
}
// -- Cache generation (per-actor invalidation) -------------------
//
// A monotonically increasing counter per actor, folded into the scan
// and check cache keys. Bumping it (a single-key INCR — cluster-safe,
// no CROSSSLOT pattern scan) instantly orphans every cached reading for
// that actor: subsequent lookups compute a new key and miss.
//
// Derived actors (app-under-user, access-token) fold the generations of
// every actor they act through into their cache keys — see
// PermissionService's cacheGenerationTag — so a bump of `user:<uuid>`
// also orphans that user's app and token actors' readings.
//
// The authoritative counter lives in shared Redis. Permission checks are
// extremely hot, so each node keeps a tiny in-process cache (the kv.js
// singleton, ~2s TTL) in front of the Redis read — this collapses the
// per-check round-trip and the `checkMany`/recursive-scan fan-out to
// in-memory lookups. A bump on any node updates that node's local copy
// immediately and propagates to other nodes within the local TTL, which
// is therefore the cross-node revocation lag. Old-generation cache keys
// expire via their own short TTL.
#cacheGenerationKey(actorUid: string): string {
return PermissionUtil.join('permission-cachegen', actorUid);
}
#localCacheGenerationKey(actorUid: string): string {
return `permgen-local:${actorUid}`;
}
async getCacheGeneration(actorUid: string): Promise<number> {
const localKey = this.#localCacheGenerationKey(actorUid);
const local = kv.get(localKey);
if (typeof local === 'number') return local;
try {
const raw = await this.clients.redis.get(
this.#cacheGenerationKey(actorUid),
);
const n = raw === null ? 0 : Number.parseInt(raw, 10);
const generation = Number.isFinite(n) && n >= 0 ? n : 0;
kv.set(localKey, generation, {
EX: PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS,
});
return generation;
} catch {
// On a cache read failure, fall back to generation 0 — the worst
// case is a stale reading bounded by the scan-cache TTL.
return 0;
}
}
async bumpCacheGeneration(actorUid: string): Promise<void> {
const key = this.#cacheGenerationKey(actorUid);
try {
const next = await this.clients.redis.incr(key);
// Keep the counter alive well past the cache TTL so it can't
// reset to 0 and revive same-generation stale entries.
await this.clients.redis.expire(
key,
PERMISSION_CACHE_GENERATION_TTL_SECONDS,
);
// Make this node consistent immediately; other nodes pick up the
// new value when their local copy expires (≤ local TTL).
if (typeof next === 'number') {
kv.set(this.#localCacheGenerationKey(actorUid), next, {
EX: PERMISSION_CACHE_GENERATION_LOCAL_TTL_SECONDS,
});
} else {
kv.del(this.#localCacheGenerationKey(actorUid));
}
} catch {
// Best-effort: if the bump fails, the scan-cache TTL still
// bounds how long a revoked grant can linger. Drop the local
// copy so we re-read from Redis rather than serve a stale gen.
kv.del(this.#localCacheGenerationKey(actorUid));
}
}
// -- Scan cache (redis) ------------------------------------------
buildScanCacheKey(actorUid: string, permissionOptions: string[]): string {
buildScanCacheKey(
actorUid: string,
permissionOptions: string[],
generation: number | string = 0,
): string {
return PermissionUtil.join(
'permission-scan',
actorUid,
`g${generation}`,
'options-list',
...permissionOptions,
);
@@ -594,14 +686,19 @@ export class PermissionStore extends PuterStore {
// -- Per-permission check cache (for `checkMany`) -----------------
//
// Cached as `1`/`0` per (actor, permission) pair so a batch lookup
// reduces to a single MGET. Same TTL as the scan cache — the
// underlying perm tables already publish invalidations via
// `publishCacheKeys` for grant/revoke writes, but those don't reach
// these keys, so we keep the TTL short and rely on it for staleness.
#checkCacheKey(actorUid: string, permission: string): string {
// reduces to a single MGET. Same TTL as the scan cache. The cache
// generation (see above) is folded into the key, so a grant/revoke
// bump invalidates these entries immediately rather than waiting for
// the TTL to lapse.
#checkCacheKey(
actorUid: string,
permission: string,
generation: number | string = 0,
): string {
return PermissionUtil.join(
'permission-check',
actorUid,
`g${generation}`,
'p',
permission,
);
@@ -610,10 +707,13 @@ export class PermissionStore extends PuterStore {
async getMultiCheckCache(
actorUid: string,
permissions: string[],
generation: number | string = 0,
): Promise<Map<string, boolean>> {
const out = new Map<string, boolean>();
if (permissions.length === 0) return out;
const keys = permissions.map((p) => this.#checkCacheKey(actorUid, p));
const keys = permissions.map((p) =>
this.#checkCacheKey(actorUid, p, generation),
);
let raw: Array<string | null> = [];
try {
raw = (await this.clients.redis.mget(...keys)) as Array<
@@ -633,13 +733,14 @@ export class PermissionStore extends PuterStore {
async setMultiCheckCache(
actorUid: string,
entries: Array<{ permission: string; granted: boolean }>,
generation: number | string = 0,
ttlSeconds: number = PERMISSION_SCAN_CACHE_TTL_SECONDS,
): Promise<void> {
if (entries.length === 0) return;
const pipeline = this.clients.redis.pipeline();
for (const { permission, granted } of entries) {
pipeline.set(
this.#checkCacheKey(actorUid, permission),
this.#checkCacheKey(actorUid, permission, generation),
granted ? '1' : '0',
'EX',
ttlSeconds,
+10 -2
View File
@@ -43,6 +43,8 @@ export interface UserRow {
requires_email_confirmation?: boolean;
/** Metadata JSON blob; decoded on read when the DB returns it as a string. */
metadata?: Record<string, unknown>;
/** Abuse v2 reputation score recorded at signup (DB default 100). */
reputation?: number;
password?: string;
[k: string]: unknown;
}
@@ -324,6 +326,7 @@ export class UserStore extends PuterStore {
signup_server?: string | null;
referrer?: string | null;
last_activity_ts?: string | null;
reputation?: number | null;
}): Promise<UserRow> {
assertLatin1Writable(fields as Record<string, unknown>);
const result = await this.clients.db.write(
@@ -344,8 +347,9 @@ export class UserStore extends PuterStore {
signup_origin,
signup_server,
referrer,
last_activity_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
last_activity_ts,
reputation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)${this.clients.db.returningIdClause()}`,
[
fields.username,
fields.email,
@@ -368,6 +372,8 @@ export class UserStore extends PuterStore {
fields.signup_server ?? null,
fields.referrer ?? null,
fields.last_activity_ts ?? null,
// Default matches the DB column default + v2's STARTING_REPUTATION.
fields.reputation ?? 100,
],
);
@@ -577,6 +583,8 @@ export class UserStore extends PuterStore {
requires_email_confirmation: asBool(
rest.requires_email_confirmation,
),
reputation:
rest.reputation == null ? undefined : Number(rest.reputation),
metadata,
};
}
+9 -5
View File
@@ -140,6 +140,8 @@ export interface IAIProviderConfig {
apiToken?: string;
/** Override the provider's HTTP base URL (OpenRouter, Cloudflare, ElevenLabs, Ollama). */
apiBaseUrl?: string;
/** Azure AI Foundry deployment endpoint (azure-openai). Required alongside `apiKey`. */
apiURL?: string;
/** Cloudflare account id. */
accountId?: string;
/** ElevenLabs default voice id. */
@@ -505,11 +507,13 @@ interface IConfigOptional {
*/
allow_v1_app_migration: boolean;
/**
* Optional explicit allowlist of `Origin` header values that may
* call `POST /auth/migrate-token` cross-origin. The main `origin`
* is always allowed. Used to thread the SDK migration call through
* app subdomains (e.g. `*.puter.site`) without opening the
* endpoint to arbitrary attacker pages.
* Optional explicit allowlist of `Origin` header values that are
* trusted by `POST /auth/migrate-token` to receive the
* `puter_token_v2` companion cookie. The main `origin` is always
* trusted. The token exchange itself is open to any browser origin
* (the v1 bearer token is the credential); this list only gates
* cookie issuance, so attacker pages can't plant a session cookie
* on the GUI origin.
*/
allow_migrate_token_origins?: string[];
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
+53
View File
@@ -0,0 +1,53 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { isUniqueViolation } from './dbError.js';
describe('isUniqueViolation', () => {
it('matches UNIQUE / PRIMARY KEY violations across the supported drivers', () => {
expect(isUniqueViolation({ code: '23505' })).toBe(true); // postgres
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT_UNIQUE' })).toBe(
true,
);
expect(
isUniqueViolation({ code: 'SQLITE_CONSTRAINT_PRIMARYKEY' }),
).toBe(true);
expect(isUniqueViolation({ code: 'ER_DUP_ENTRY' })).toBe(true); // mysql
expect(isUniqueViolation({ errno: 1062 })).toBe(true); // mysql errno
});
it('does NOT match other constraint / error kinds, which must bubble up', () => {
// The bare parent code can be a CHECK / NOT NULL / FK violation.
expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(false);
expect(
isUniqueViolation({ code: 'SQLITE_CONSTRAINT_FOREIGNKEY' }),
).toBe(false);
expect(isUniqueViolation({ code: '23502' })).toBe(false); // pg NOT NULL
expect(isUniqueViolation({ code: '23503' })).toBe(false); // pg FK
expect(isUniqueViolation({ errno: 1452 })).toBe(false); // mysql FK
});
it('is safe on non-error inputs', () => {
expect(isUniqueViolation(null)).toBe(false);
expect(isUniqueViolation(undefined)).toBe(false);
expect(isUniqueViolation('boom')).toBe(false);
expect(isUniqueViolation({})).toBe(false);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* 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/>.
*/
/**
* True when `err` is a UNIQUE / PRIMARY KEY constraint violation, across the
* three database drivers Puter runs on. Use to make an "insert if absent"
* idempotent in the face of a lost check-then-insert race — only a duplicate
* is swallowed; CHECK / NOT NULL / FK / type violations still bubble up.
*
* better-sqlite3 surfaces `SqliteError.code`; mysql2 surfaces `.code` and
* `.errno` (1062 = ER_DUP_ENTRY); pg surfaces SQLSTATE `23505`.
*/
export function isUniqueViolation(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const { code, errno } = err as { code?: string; errno?: number };
// Match only UNIQUE / PRIMARY KEY violations. The bare `SQLITE_CONSTRAINT`
// parent code is intentionally excluded so CHECK / NOT NULL / FK / type
// violations still bubble up rather than being silently swallowed.
if (
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ||
code === 'ER_DUP_ENTRY' ||
code === '23505'
) {
return true;
}
return errno === 1062;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import type { FSEntry } from '../stores/fs/FSEntry.js';
import {
isSignatureValid,
signFile,
verifySignature,
} from './fileSigning.js';
const CONFIG = {
secret: 'a-real-secret-not-the-placeholder',
apiBaseUrl: 'https://api.example.test',
};
const makeEntry = (): FSEntry =>
({
uuid: '11111111-2222-3333-4444-555555555555',
name: 'doc.txt',
isDir: false,
size: 10,
accessed: 0,
modified: 0,
created: 0,
}) as unknown as FSEntry;
const queryFromUrl = (url: string) => {
const u = new URL(url);
return {
uid: u.searchParams.get('uid') ?? undefined,
expires: u.searchParams.get('expires') ?? undefined,
signature: u.searchParams.get('signature') ?? undefined,
};
};
describe('fileSigning round-trip', () => {
it('a freshly signed read URL verifies for read', () => {
const signed = signFile(makeEntry(), CONFIG);
expect(() =>
verifySignature(queryFromUrl(signed.read_url), 'read', CONFIG),
).not.toThrow();
});
it('a write signature also satisfies read (superset)', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.write_url!);
expect(isSignatureValid(q, 'write', CONFIG)).toBe(true);
expect(isSignatureValid(q, 'read', CONFIG)).toBe(true);
});
it('a read signature does NOT satisfy write', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.read_url);
expect(isSignatureValid(q, 'write', CONFIG)).toBe(false);
});
});
describe('fileSigning rejection paths', () => {
it('rejects a tampered signature', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.read_url);
q.signature = (q.signature ?? '').replace(/^./, (c) =>
c === 'a' ? 'b' : 'a',
);
expect(isSignatureValid(q, 'read', CONFIG)).toBe(false);
});
it('rejects a signature uid swapped to a different file', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.read_url);
q.uid = '99999999-9999-9999-9999-999999999999';
expect(isSignatureValid(q, 'read', CONFIG)).toBe(false);
});
it('rejects a signature minted under a different secret', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.read_url);
expect(
isSignatureValid(q, 'read', {
...CONFIG,
secret: 'a-different-secret',
}),
).toBe(false);
});
it('rejects an expired signature', () => {
const signed = signFile(makeEntry(), CONFIG, { ttlSeconds: -10 });
expect(() =>
verifySignature(queryFromUrl(signed.read_url), 'read', CONFIG),
).toThrow(/expired/i);
});
it('rejects malformed (non-hex / wrong-length) signatures without throwing in the comparator', () => {
const signed = signFile(makeEntry(), CONFIG);
const q = queryFromUrl(signed.read_url);
for (const bad of ['', 'zz', 'not-hex-at-all', 'abc']) {
expect(isSignatureValid({ ...q, signature: bad }, 'read', CONFIG)).toBe(
false,
);
}
});
});
+30 -3
View File
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash } from 'node:crypto';
import { createHash, timingSafeEqual } from 'node:crypto';
import { HttpError } from '../core/http/HttpError.js';
import type { FSEntry } from '../stores/fs/FSEntry.js';
@@ -66,6 +66,23 @@ function computeSignature(
return sha256(`${uid}/${action}/${secret}/${expires}`);
}
/**
* Constant-time equality for the hex signature strings. A plain `===`
* short-circuits on the first differing character, leaking a byte-by-byte
* timing oracle on the one value an attacker controls and submits
* repeatedly. Length mismatch (or non-hex input) returns false without
* a timing-variable compare.
*/
function signaturesEqual(provided: string, expected: string): boolean {
if (provided.length !== expected.length) return false;
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
// Malformed hex yields a shorter buffer than the hex length implies;
// timingSafeEqual requires equal lengths, so guard before comparing.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Produce a signed-URL object. The default `expires` timestamp uses a
* ~317k-year TTL (effectively permanent) — existing clients depend on that
@@ -152,9 +169,19 @@ export function verifySignature(
}
// Write signature satisfies any action.
if (signature === computeSignature(uid, 'write', config.secret, expires))
if (
signaturesEqual(
signature,
computeSignature(uid, 'write', config.secret, expires),
)
)
return;
if (signature === computeSignature(uid, action, config.secret, expires))
if (
signaturesEqual(
signature,
computeSignature(uid, action, config.secret, expires),
)
)
return;
throw new HttpError(403, 'Authentication failed', {
@@ -0,0 +1,74 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from 'express';
import { describe, expect, it } from 'vitest';
import { applyInlineContentSecurity } from './inlineContentSecurity.js';
const makeRes = () => {
const headers: Record<string, string> = {};
return {
res: {
setHeader: (k: string, v: string) => {
headers[k.toLowerCase()] = v;
},
} as unknown as Response,
headers,
};
};
describe('applyInlineContentSecurity', () => {
it.each([
'text/html',
'text/html; charset=utf-8',
'image/svg+xml',
'application/xhtml+xml',
'application/xml',
'text/xml',
'TEXT/HTML',
])('sandboxes active-document type %s', (ct) => {
const { res, headers } = makeRes();
expect(applyInlineContentSecurity(res, ct)).toBe(true);
expect(headers['content-security-policy']).toBe(
"default-src 'none'; sandbox;",
);
expect(headers['x-content-type-options']).toBe('nosniff');
});
it.each([
'image/png',
'image/jpeg',
'audio/mpeg',
'video/mp4',
'application/octet-stream',
'application/pdf',
'text/plain',
])('leaves inert type %s untouched', (ct) => {
const { res, headers } = makeRes();
expect(applyInlineContentSecurity(res, ct)).toBe(false);
expect(headers['content-security-policy']).toBeUndefined();
});
it('no-ops on null/undefined content type', () => {
const { res, headers } = makeRes();
expect(applyInlineContentSecurity(res, null)).toBe(false);
expect(applyInlineContentSecurity(res, undefined)).toBe(false);
expect(headers['content-security-policy']).toBeUndefined();
});
});
+51
View File
@@ -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/>.
*/
import type { Response } from 'express';
// Content types a browser will execute as a *document* (running embedded
// scripts) when loaded top-level — even when the type is declared honestly.
// An uploaded SVG with a `<script>` runs when navigated to directly; HTML
// obviously does. Served inline on an api/file origin, that's stored XSS.
const ACTIVE_DOCUMENT_CONTENT_TYPE =
/^(text\/html|image\/svg\+xml|application\/xhtml\+xml|application\/xml|text\/xml)\b/i;
/**
* For file-serving endpoints that echo an uploader-controlled content type
* inline: when the type is one a browser executes as a document, apply the
* same sandbox CSP used for app icons so embedded scripts can't run with
* the serving origin. No-op for inert types (images, audio, octet-stream),
* so `<img>`/download/range consumers are unaffected.
*
* Returns true if the sandbox header was applied (useful for tests/logging).
*/
export function applyInlineContentSecurity(
res: Response,
contentType: string | null | undefined,
): boolean {
if (contentType && ACTIVE_DOCUMENT_CONTENT_TYPE.test(contentType)) {
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; sandbox;",
);
res.setHeader('X-Content-Type-Options', 'nosniff');
return true;
}
return false;
}
+75 -2
View File
@@ -17,8 +17,40 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { isPublicResolvedAddress, validateUrlNoIP } from './secureHttp.js';
import { describe, expect, it, vi } from 'vitest';
import { configContainer } from '../exports.js';
import {
isPublicResolvedAddress,
secureFetch,
validateUrlNoIP,
} from './secureHttp.js';
vi.mock('node:dns', async importOriginal => {
const actual = await importOriginal<typeof import('node:dns')>();
const PRIVATE_HOSTS: Record<string, string> = {
'resolves-private.test': '10.0.0.1',
'resolves-metadata.test': '169.254.169.254',
};
return {
...actual,
lookup: ((hostname: string, options: unknown, callback: unknown) => {
const cb = callback as (
err: Error | null,
addresses?: { address: string; family: number }[],
) => void;
const addr = PRIVATE_HOSTS[hostname];
if (addr) {
cb(null, [{ address: addr, family: 4 }]);
return;
}
(actual.lookup as (...args: unknown[]) => void)(
hostname,
options,
callback,
);
}) as typeof actual.lookup,
};
});
describe('secureHttp URL validation', () => {
it('rejects raw IP and localhost URLs before fetching', () => {
@@ -43,6 +75,47 @@ describe('secureHttp resolved address validation', () => {
}
});
it('refuses to connect when a hostname resolves to a private or reserved address', async () => {
// Regression test: the resolved-address check must run on the
// connection itself, not just exist as a helper — see guardedLookup.
for (const url of [
'http://resolves-private.test/',
'http://resolves-metadata.test/',
]) {
await expect(secureFetch(url)).rejects.toSatisfy((e: unknown) => {
const err = e as Error & { cause?: Error };
return /private or reserved/.test(
err.cause?.message ?? err.message,
);
});
}
});
it('does not apply the connect-time guard when routed through the CORS proxy', async () => {
// The proxy is admin-trusted config and may legitimately sit on a
// private address; the user-supplied host is resolved by the proxy
// worker, not locally. Point the proxy at a host our DNS mock
// resolves to a private address: if the guard were (incorrectly)
// applied, we'd see the SSRF refusal — instead the connection must
// fail with an ordinary DNS error from the real resolver.
configContainer.secureCorsProxy = {
url: 'http://resolves-private.test/?url=',
secret: 'test-secret',
};
try {
await expect(
secureFetch('http://some-user-supplied-host.test/'),
).rejects.toSatisfy((e: unknown) => {
const err = e as Error & { cause?: Error };
return !/private or reserved/.test(
err.cause?.message ?? err.message,
);
});
} finally {
delete configContainer.secureCorsProxy;
}
});
it('rejects private, link-local, loopback, mapped, and reserved addresses', () => {
for (const address of [
'0.0.0.0',
+70 -4
View File
@@ -17,7 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
import net, { BlockList } from 'node:net';
import { Agent as UndiciAgent, fetch as undiciFetch } from 'undici';
import { HttpError } from '../core/http/HttpError.js';
import { configContainer } from '../exports.js';
import type { ISecureCorsProxyConfig } from '../types.js';
@@ -101,6 +103,57 @@ export function isPublicResolvedAddress(address: string): boolean {
return !BLOCKED_RESOLVED_IPS.check(address, family === 6 ? 'ipv6' : 'ipv4');
}
// Connect-time DNS guard: every address a hostname resolves to must be
// public, and the check runs on the resolution the socket actually uses —
// a validate-then-fetch design re-resolves at connect time, so checking
// here (rather than before fetch) is what defeats DNS rebinding.
const guardedLookup: net.LookupFunction = (hostname, options, callback) => {
// `net`'s lookup hook can be handed `options` as either an object or a
// bare address-family number. Normalize so we can both resolve all
// addresses and report errors back in the arity the caller expects:
// `(err, addresses[])` when `all` was requested, `(err, address, family)`
// otherwise.
const opts = typeof options === 'number' ? { family: options } : options;
const wantAll = opts.all === true;
const fail = (err: Error) => {
if (wantAll) {
callback(err, []);
} else {
callback(err, '', 0);
}
};
dnsLookup(hostname, { ...opts, all: true }, (err, addresses) => {
if (err) {
fail(err);
return;
}
const list = addresses as LookupAddress[];
if (
list.length === 0 ||
list.some((a) => !isPublicResolvedAddress(a.address))
) {
fail(
Object.assign(
new Error(
`refusing to connect: ${hostname} resolves to a private or reserved address`,
),
{ code: 'ERR_SSRF_BLOCKED' },
),
);
return;
}
if (wantAll) {
callback(null, list);
} else {
callback(null, list[0].address, list[0].family);
}
});
};
const ssrfGuardDispatcher = new UndiciAgent({
connect: { lookup: guardedLookup },
});
function proxyConfig(): ISecureCorsProxyConfig | undefined {
const cfg = configContainer.secureCorsProxy;
if (cfg?.url && cfg?.secret) return cfg;
@@ -117,8 +170,10 @@ interface SecureFetchInit extends Omit<RequestInit, 'redirect'> {
* • Rejects raw-IP / localhost hosts via {@link validateUrlNoIP}.
* • Forces `redirect: 'manual'` and rejects any 3xx response so a permissive
* target can't bounce us onto an internal endpoint.
* • Resolves DNS through Cloudflare's 1.1.1.3 (malware-filtered) resolver
* and rejects private/reserved answers before connecting.
* • Re-checks DNS at connect time: every address the socket actually
* connects to must pass {@link isPublicResolvedAddress}, so a hostname
* resolving to a private/reserved address (including via DNS rebinding)
* is rejected.
* • If `config.secureCorsProxy` is set AND the URL isn't a `data:` URI,
* rewrites the request through the configured signed Cloudflare Worker
* proxy (adds `x-cors-proxy-auth-secret`).
@@ -149,12 +204,23 @@ export async function secureFetch(
}
}
// The connect-time DNS guard only applies to direct fetches. When the
// request is rewritten through the CORS proxy, the only host resolved
// locally is the proxy itself — admin-trusted config that may
// legitimately sit on a private address — and the user-supplied host
// is resolved by the proxy worker from Cloudflare's network, where
// this deployment's private ranges aren't reachable.
const proxied = finalUrl !== url;
const { skipProxy: _skip, ...rest } = init;
const response = await fetch(finalUrl, {
// lib.dom and undici fetch types are structurally equivalent here but
// nominally distinct; cast across the boundary.
const response = (await undiciFetch(finalUrl, {
...rest,
headers,
redirect: 'manual',
});
...(proxied ? {} : { dispatcher: ssrfGuardDispatcher }),
} as unknown as Parameters<typeof undiciFetch>[1])) as unknown as Response;
if (response.status >= 300 && response.status < 400) {
throw new HttpError(
+1 -1
View File
@@ -6,7 +6,7 @@
## Install
```sh
npm install @heyputer/cli
npm install -g @heyputer/cli
```
Requires Node 18+.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@heyputer/cli",
"version": "0.1.0",
"version": "0.1.2",
"description": "CLI for Puter Platform - manage your sites and workers from the terminal.",
"license": "MIT",
"author": "Puter Technologies Inc.",
@@ -23,7 +23,7 @@
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@heyputer/puter.js": "latest",
"@heyputer/puter.js": "^2.5.1",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"conf": "^13.0.0"
+25 -5
View File
@@ -5,7 +5,7 @@ description: Run and manage serverless JavaScript funcitons in the cloud.
Serverless Workers are serverless functions that run JavaScript code in the cloud.
Workers run server-side, which makes them a good fit for centralized application data and backend logic. See [Integration with Puter.js](/Workers/router/#integration-with-puterjs) for how worker code accesses Puter resources.
Workers run server-side, which makes them a good fit for centralized application data and backend logic. See [Integration with Puter.js](/Workers/router/#integration-with-puter-js) for how worker code accesses Puter resources.
## Router
@@ -173,7 +173,27 @@ The quickest way to publish a worker is to create it on [puter.com](https://pute
</li>
</ol>
<!-- ### Automate with GitHub Actions
### Deploy with the Puter CLI
You can also deploy straight from the terminal with the [Puter CLI](https://www.npmjs.com/package/@heyputer/cli).
Install it globally:
```
npm install -g @heyputer/cli
```
Then deploy your worker's JavaScript file to a `*.puter.work` subdomain:
```
puter worker deploy [file] [name]
```
Both arguments are optional — run `puter worker deploy` with no arguments and the CLI prompts you for the file and worker name.
<div class="info">The Puter CLI is currently in beta (0.x), so commands and behavior may change.</div>
### Automate with GitHub Actions
If your worker's code lives on GitHub, you can redeploy it automatically on every push using the [Puter Worker Deploy Action](https://github.com/HeyPuter/puter-worker-deploy-action).
@@ -193,13 +213,13 @@ jobs:
- uses: actions/checkout@v4
- name: Deploy worker
uses: HeyPuter/puter-worker-deploy-action@v1
uses: HeyPuter/puter-worker-deploy-action@v1.0.1
with:
worker_name: my-api # publishes to my-api.puter.work
puter_path: ~/Workers/my-api/ # where to store the files on Puter
source_path: worker # the folder containing your worker
entry_file: index.js # the worker's entry file
puter_path: ~/workers/my-api # where to store the files on Puter
puter_token: ${{ secrets.PUTER_TOKEN }}
```
<div class="info">Store your Puter auth token as a GitHub Actions secret named <code>PUTER_TOKEN</code>. See the <a href="https://github.com/HeyPuter/puter-worker-deploy-action">action's README</a> for how to obtain a token and for the full list of options.</div> -->
<div class="info">Create a new repository secret named <code>PUTER_TOKEN</code> and set its value to your Puter auth token (see <a href="https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository">creating secrets for a repository</a>). To get your auth token, follow the <a href="https://developer.puter.com/tutorials/puter-auth-token/">Puter auth token tutorial</a>.</div>
+2 -6
View File
@@ -6,13 +6,9 @@ platforms: [websites, apps, nodejs, workers]
Creates and deploys a new worker from a JavaScript file containing [router](../router) code.
<div class="info">To create a worker, you'll need a <a href="https://puter.com/">Puter account</a> with a verified email address.</div>
<div class="info">After a worker is created or updated, full propagation may take between 5 to 30 seconds to fully take effect across all edge servers. </div>
<div class="info">A worker is tied to its <strong>name</strong> — you create it <strong>once</strong> and keep that name. To deploy changes, don't call <code>create()</code> again with a new name; instead overwrite the worker's source file (see <a href="#updating-a-worker">Updating a worker</a> below). Recreating under a different name leaves the old worker live at its old URL while your callers end up pointing at an orphaned one.</div>
A worker is tied to its **name**: you create it **once** and keep that name. To deploy changes, don't call `create()` again with a new name — instead overwrite the worker's source file (see [Updating a worker](#updating-a-worker) below). Recreating under a different name leaves the old worker live at its old URL while your callers end up pointing at an orphaned one.
<div class="info">To create a worker, you'll need a <a href="https://puter.com/">Puter account</a> with a verified email address. After a worker is created or updated, full propagation may take between 5 and 30 seconds to take effect across all edge servers.</div>
## Syntax
+2
View File
@@ -82,6 +82,8 @@ router.get("/files/*path", async ({ params }) => {
});
```
A wildcard **must be named** — write `*path` (or any name you like), not a bare `*`. A pattern like `/files/*` won't act as a wildcard: with no name after it, the `*` is treated as a literal character, so the route only matches the exact path `/files/*`. The name is what gives the router a key to expose the captured value on `params`.
A common use is a catch-all route for unmatched paths — define it last so it only runs when nothing else matched (see the [404 Handler](#examples) example below).
## CORS
+22 -3
View File
@@ -93,9 +93,28 @@ $(document).on('click', 'a:not(.skip-insta-load):not([target="_blank"])', functi
$('#toc-wrapper').html($(data).find('#toc-wrapper').html());
setTimeout(() => {
$('body').animate({
scrollTop: 0,
}, 100);
// If the URL has a hash, scroll to that heading instead of the
// top of the page. The browser only does this natively on a full
// page load, so the insta-loader has to handle it here.
const hash = window.location.hash;
let target = null;
if ( hash.length > 1 ) {
const id = hash.slice(1);
try {
// Pasted/hand-typed URLs may contain malformed
// percent-encoding, which makes decodeURIComponent throw.
target = document.getElementById(decodeURIComponent(id));
} catch ( e ) {
target = document.getElementById(id);
}
}
if ( target ) {
target.scrollIntoView();
} else {
$('body').animate({
scrollTop: 0,
}, 100);
}
}, 30);
//set title of page
+192
View File
@@ -0,0 +1,192 @@
---
title: CLI
description: Manage your Puter resources directly from your terminal with the Puter CLI. Deploy static sites and serverless workers without leaving your shell.
---
The [Puter CLI](https://www.npmjs.com/package/@heyputer/cli) lets you manage your Puter resources straight from the terminal: deploy static websites, ship serverless workers, and inspect the apps registered to your account, all without leaving your shell.
<div class="info">The Puter CLI is in beta (0.x). Behavior may change between releases.</div>
## Installation
Install the CLI globally with npm (requires Node 18+):
```sh
npm install -g @heyputer/cli
```
Then log in once and your token is stored for later commands:
```sh
puter login
```
This opens your browser to authenticate with Puter. Once you're logged in, you're ready to deploy.
## Authentication
`puter login` runs an interactive browser flow and saves your token for future commands. If you don't have browser access (for example, on a remote server), pipe a token in via stdin instead:
```sh
echo "$TOKEN" | puter login --with-token
```
For automation and CI, set the `PUTER_AUTH_TOKEN` environment variable and the CLI skips login entirely, reading the token from the environment on every command.
```sh
puter whoami # show the current account
puter logout # clear the stored token
```
## Sites
Deploy a static directory to a `<subdomain>.puter.site` address, then list, inspect, or remove your sites.
```sh
puter site deploy ./dist my-app
```
Run `puter site deploy` with no arguments and the CLI prompts you for the directory and subdomain interactively, suggesting an available name. Deploys are versioned: each deploy uploads into its own folder, so previous versions are preserved.
```sh
puter site deploy [dir] [subdomain] # deploy a directory
puter site list # list your sites
puter site get <subdomain> # show one site's details
puter site delete <subdomain> # remove a site
```
## Workers
Deploy a single JavaScript file as a serverless [Worker](/Workers/), served at `<name>.puter.work`. Deploying with a name that already exists replaces that worker's code in place.
```sh
puter worker deploy ./api.js my-api
```
As with sites, running `puter worker deploy` with no arguments prompts you for the file and name.
```sh
puter worker deploy [file] [name] # deploy or replace a worker
puter worker list # list your workers
puter worker get <name> # show one worker's details
puter worker delete <name> # delete a worker
```
## Apps
Browse the apps registered to your account. These commands are read-only.
```sh
puter app list # list your apps
puter app get <name> # show one app's details
```
## CLI reference
### Global options
| Option | Description |
| --- | --- |
| `-v`, `--version` | Print the CLI version. |
| `-h`, `--help` | Show help for any command, e.g. `puter site deploy --help`. |
The CLI detects whether it's running interactively. In a terminal it prompts for any missing values; in a non-interactive context (CI, piped output, or with `CI` set) it never prompts, so required arguments must be passed explicitly.
### `puter login`
Log in to Puter and store the token for later commands.
| Argument / Option | Description |
| --- | --- |
| `--with-token` | Read an auth token from stdin instead of opening a browser. |
### `puter logout`
Clear the stored auth token. Takes no arguments.
### `puter whoami`
Show the account associated with the current token. Takes no arguments.
### `puter site deploy`
Deploy a static directory to `<subdomain>.puter.site`.
| Argument | Description |
| --- | --- |
| `[dir]` | Directory to deploy. Prompted for when omitted interactively. |
| `[subdomain]` | Target subdomain. Prompted for when omitted interactively; a pasted full host like `my-app.puter.site` is accepted. |
In non-interactive mode both arguments are required. Subdomains may use lowercase letters, numbers, and hyphens (not at the ends).
### `puter site list`
List the subdomains you own, with their URLs. Takes no arguments.
### `puter site get`
Show details for one site.
| Argument | Description |
| --- | --- |
| `<subdomain>` | The subdomain to inspect. |
### `puter site delete`
Remove a subdomain.
| Argument / Option | Description |
| --- | --- |
| `<subdomain>` | The subdomain to delete. |
| `-y`, `--yes` | Skip the confirmation prompt. |
### `puter worker deploy`
Deploy a JavaScript file as a serverless worker at `<name>.puter.work`, or replace an existing one.
| Argument | Description |
| --- | --- |
| `[file]` | The worker's JavaScript file. Prompted for when omitted interactively. |
| `[name]` | Worker name. Prompted for when omitted interactively. |
In non-interactive mode both arguments are required. Names may use letters, numbers, and hyphens (not at the ends).
### `puter worker list`
List your workers, with their URLs. Takes no arguments.
### `puter worker get`
Show details for one worker.
| Argument | Description |
| --- | --- |
| `<name>` | The worker to inspect. |
### `puter worker delete`
Delete a worker and its backing file.
| Argument / Option | Description |
| --- | --- |
| `<name>` | The worker to delete. |
| `-y`, `--yes` | Skip the confirmation prompt. |
### `puter app list`
List the apps registered to your account. Takes no arguments.
### `puter app get`
Show details for one app.
| Argument | Description |
| --- | --- |
| `<name>` | The app to inspect. |
## Environment variables
| Variable | Description |
| --- | --- |
| `PUTER_AUTH_TOKEN` | Auth token to use instead of logging in. Takes precedence over the stored token. |
| `CI` | When set, the CLI runs non-interactively and never prompts. |
+29 -9
View File
@@ -9,9 +9,9 @@ Puter.js is a regular JavaScript library, so your app deploys like any other web
## Deploy anywhere
Because Puter.js runs entirely in the browser, there's no special backend to provision. Build your app as you normally would and serve the output from any static or web hosting provider, such as <a href="https://vercel.com" rel="nofollow">Vercel</a>, <a href="https://pages.cloudflare.com" rel="nofollow">Cloudflare Pages</a>, <a href="https://www.netlify.com" rel="nofollow">Netlify</a>, or <a href="https://pages.github.com" rel="nofollow">GitHub Pages</a>.
Because Puter.js runs entirely in the browser, there's no special backend to provision. Build and serve your app the same way you would any other website, on any hosting provider, such as <a href="https://vercel.com" rel="nofollow">Vercel</a>, <a href="https://pages.cloudflare.com" rel="nofollow">Cloudflare Pages</a>, <a href="https://www.netlify.com" rel="nofollow">Netlify</a>, or <a href="https://pages.github.com" rel="nofollow">GitHub Pages</a>.
<div class="info">Your app should be served over HTTPS by a web server, like any of the hosting providers above.</div>
<div class="info">The only requirement is that the app is served by a web server. A hosting provider, a self-hosted server, and a local development server are all valid. Opening the HTML file directly from disk does not work.</div>
No extra configuration is required. Your app keeps talking to Puter's services from the browser, wherever it's hosted.
@@ -50,7 +50,27 @@ The quickest way to publish a website is to upload it on [puter.com](https://put
</li>
</ol>
<!-- ### Automate with GitHub Actions
### Deploy with the Puter CLI
You can also deploy straight from the terminal with the [Puter CLI](https://www.npmjs.com/package/@heyputer/cli).
Install it globally:
```
npm install -g @heyputer/cli
```
Then deploy your site's directory to a `*.puter.site` subdomain:
```
puter site deploy [dir] [subdomain]
```
Both arguments are optional: run `puter site deploy` with no arguments and the CLI prompts you for the directory and subdomain.
<div class="info">The Puter CLI is currently in beta (0.x), so commands and behavior may change.</div>
### Automate with GitHub Actions
If your code lives on GitHub, you can redeploy your site automatically on every push using the [Puter Subdomain Deploy Action](https://github.com/HeyPuter/puter-subdomain-deploy-action).
@@ -70,14 +90,14 @@ jobs:
- uses: actions/checkout@v4
- name: Deploy website
uses: HeyPuter/puter-subdomain-deploy-action@v1
uses: HeyPuter/puter-subdomain-deploy-action@v1.0.6
with:
subdomain: my-site # publishes to my-site.puter.site
source_path: dist # the folder to deploy (e.g. your build output)
puter_path: ~/sites/my-site # where to store the files on Puter
subdomain: my-site # publishes to my-site.puter.site
puter_path: ~/Sites/my-site/deployment/ # where to store the files on Puter
source_path: dist # the folder to deploy (e.g. your build output)
puter_token: ${{ secrets.PUTER_TOKEN }}
```
<div class="info">Store your Puter auth token as a GitHub Actions secret named <code>PUTER_TOKEN</code>. See the <a href="https://github.com/HeyPuter/puter-subdomain-deploy-action">action's README</a> for how to obtain a token and for the full list of options.</div>
<div class="info">Create a new repository secret named <code>PUTER_TOKEN</code> and set its value to your Puter auth token (see <a href="https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository">creating secrets for a repository</a>). To get your auth token, follow the <a href="https://developer.puter.com/tutorials/puter-auth-token/">Puter auth token tutorial</a>.</div>
If your project has a build step, run it before the deploy step (for example `npm ci && npm run build`) and point `source_path` at the build output. -->
If your project has a build step, run it before the deploy step (for example `npm ci && npm run build`) and point `source_path` at the build output.
+98
View File
@@ -0,0 +1,98 @@
---
title: MCP Server
description: Connect your AI tools to Puter with the Puter MCP server, and let them act on your Puter resources on your behalf.
---
[MCP (Model Context Protocol)](https://modelcontextprotocol.io) is the standard for connecting LLMs to platforms like Puter. With the Puter MCP server, you can let your AI tools (Claude Code, Codex, or any other MCP-compatible client) interact with your Puter resources on your behalf: managing files, publishing websites, deploying workers, and more.
## Installation
The Puter MCP server is hosted at [mcp.puter.com](https://mcp.puter.com). There's nothing to install or run yourself. Just point your AI tool at it and authenticate with your Puter account.
<div style="overflow:hidden; margin-top: 30px;">
<div class="example-group active" data-section="claude-code"><span>Claude Code</span></div>
<div class="example-group" data-section="codex"><span>Codex</span></div>
</div>
<div class="example-content" data-section="claude-code" style="display:block;">
Run this command in your terminal:
```bash
claude mcp add --transport http --scope user puter https://mcp.puter.com/
```
Then run `/mcp` inside Claude Code to authenticate with Puter.
</div>
<div class="example-content" data-section="codex">
Run this command in your terminal:
```bash
codex mcp add puter --url https://mcp.puter.com/
```
You'll be sent to authenticate with Puter automatically.
</div>
<div class="info">Using a different MCP client? Point it at the HTTP endpoint <code>https://mcp.puter.com/</code>. The server uses OAuth, so your client will guide you through signing in to Puter.</div>
## Usage
Once connected, just ask your LLM to interact with your Puter account in plain language. For example:
- "List the files in my Puter home directory."
- "Publish the `dist` folder as a website."
- "Deploy this script as a Puter worker and give me the URL."
Your AI tool picks the right Puter tools to carry out the request, acting as you.
## Tools
The Puter MCP server exposes the following tools, grouped by category. Each one mirrors the equivalent [Puter.js](/) SDK call.
### Filesystem
- `fs_write_file`: Create or overwrite a file in your Puter filesystem.
- `fs_read_file`: Read a file's contents (UTF-8 text, or base64 for binary).
- `fs_readdir`: List the files and subdirectories in a directory.
- `fs_mkdir`: Create a directory, optionally creating missing parents.
- `fs_stat`: Get metadata (name, size, type, timestamps) for a file or directory.
- `fs_delete`: Delete a file or directory.
### Hosting
- `hosting_create`: Publish a static website, served at `<subdomain>.puter.site`.
- `hosting_list`: List the websites you've published.
- `hosting_get`: Get a single published website and the directory it serves.
- `hosting_update`: Re-point a website at a different directory.
- `hosting_delete`: Unpublish a website.
### Workers
- `workers_create`: Deploy a serverless [Worker](/Workers/) from a JavaScript file and get its public URL.
- `workers_exec`: Call a deployed worker over HTTP, authenticated as you.
- `workers_list`: List your deployed workers.
- `workers_get`: Get a single worker's public URL and source file.
- `workers_delete`: Undeploy a worker.
### Apps
- `apps_create`: Register a launchable Puter app pointing at a URL.
- `apps_check_name`: Check whether an app name is available before creating it.
- `apps_list`: List the apps you own.
- `apps_get`: Get a single app, including its usage stats.
- `apps_update`: Update or rename an existing app.
- `apps_delete`: Unregister an app.
### Documentation
- `puter_docs_index`: Load the index of Puter.js documentation to discover available topics.
- `puter_docs_get`: Fetch a specific documentation page as Markdown.
### Account
- `whoami`: Get your account info, including username and home directory.
+10
View File
@@ -28,6 +28,16 @@ let sidebar = [
source: '/frameworks.md',
path: '/frameworks',
},
{
title: 'MCP Server',
source: '/mcp.md',
path: '/mcp',
},
{
title: 'CLI',
source: '/cli.md',
path: '/cli',
},
{
title: 'Deployments',
source: '/deployments.md',
+9
View File
@@ -91,6 +91,15 @@ const ipc_listener = async (event, handled) => {
return handled.resolve(false);
}
// The sender must be the iframe that owns this appInstanceID — instance
// IDs are disclosed to other apps (e.g. via `messageToApp`), so knowing
// one must not be enough to act as that app.
const owner_iframe = window.iframe_for_app_instance(event.data.appInstanceID);
if ( ! owner_iframe || event.source !== owner_iframe.contentWindow ) {
console.error('appInstanceID does not match message source');
return handled.resolve(false);
}
handled.resolve(true);
const $el_parent_window = $(window.window_for_app_instance(event.data.appInstanceID));
+4 -4
View File
@@ -101,18 +101,18 @@ const TabAccount = {
h += '</div>';
}
// Auth token card
// API token card
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('auth_token')}</strong>`;
h += `<span>${i18n('copy_token_description')}</span>`;
h += `<strong>${i18n('api_token')}</strong>`;
h += `<span>${i18n('api_token_description')}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button copy-auth-token">${i18n('copy') || 'Copy'}</button>`;
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
h += '</div>';
// Danger zone
+2 -1
View File
@@ -116,8 +116,9 @@ async function UIWindowAuthMe (options = {}) {
h += `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>`;
h += `<span style="font-size: 13px; color: #374151;">${i18n('your_auth_token')}</span>`;
h += `<span style="font-size: 13px; color: #374151;">${i18n('shared_api_token')}</span>`;
h += '</div>';
h += `<p style="margin: 8px 0 0; font-size: 12px; color: #6b7280; line-height: 1.4;">${i18n('shared_api_token_note')}</p>`;
h += '</div>';
// Buttons
+94 -15
View File
@@ -18,7 +18,14 @@
*/
import UIWindow from './UIWindow.js';
import UIWindowManageSessions from './UIWindowManageSessions.js';
import create_access_token from '../helpers/create_access_token.js';
// Creates a named, revocable full-API-access token and shows it once.
// Replaces the old "copy your raw GUI/session token" behaviour: the copied
// token used to be a session-equivalent credential that could escalate to
// full account control; the minted access token can use the whole API but is
// locked out of account management (see create_access_token.js).
function UIWindowCopyToken (options = {}) {
return new Promise(async (resolve) => {
let h = '';
@@ -46,29 +53,49 @@ function UIWindowCopyToken (options = {}) {
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>`;
h += '</div>';
h += `<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: white;">${i18n('auth_token')}</h2>`;
h += `<p style="margin: 6px 0 0; font-size: 13px; color: rgba(255,255,255,0.8); text-align: center; line-height: 1.4;">${i18n('copy_token_message')}</p>`;
h += `<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: white;">${i18n('create_api_token')}</h2>`;
h += `<p style="margin: 6px 0 0; font-size: 13px; color: rgba(255,255,255,0.8); text-align: center; line-height: 1.4;">${i18n('create_token_message')}</p>`;
h += '</div>';
}
h += '<div class="copy-token" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
if ( ! options.show_header ) {
h += `<div class="form-label" style="margin-bottom: 5px; font-size: 13px; color: #666;">${i18n('copy_token_message')}</div>`;
}
if ( options.show_close_button ) {
h += '<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>';
}
h += `<div style="display: flex; gap: 8px; margin-top: ${options.show_header ? '0' : '15'}px; margin-bottom: 15px;">`;
h += `<input type="text" class="token-input" readonly value="${html_encode(window.auth_token)}" style="flex: 1; font-family: monospace; font-size: 13px;" />`;
// -- Phase A: create form --
h += '<div class="create-token-form">';
if ( ! options.show_header ) {
h += `<div class="form-label" style="margin-bottom: 12px; font-size: 13px; color: #666;">${i18n('create_token_message')}</div>`;
}
h += '<div class="form-error-msg" style="display:none; margin-bottom: 12px;"></div>';
h += `<label class="form-label" style="font-size: 13px;" for="token-label-input">${i18n('token_label')}</label>`;
h += `<input id="token-label-input" type="text" class="token-label-input" maxlength="64" autocomplete="off" placeholder="${i18n('token_label_placeholder')}" style="width: 100%; box-sizing: border-box; margin: 5px 0 15px;" />`;
h += `<label class="form-label" style="font-size: 13px;" for="token-expiry-select">${i18n('token_expiry')}</label>`;
h += `<select id="token-expiry-select" class="token-expiry-select" style="width: 100%; box-sizing: border-box; margin: 5px 0 15px;">`;
h += `<option value="">${i18n('token_expiry_never')}</option>`;
h += `<option value="7d">${i18n('token_expiry_7d')}</option>`;
h += `<option value="30d">${i18n('token_expiry_30d')}</option>`;
h += `<option value="90d">${i18n('token_expiry_90d')}</option>`;
h += '</select>';
h += `<button class="button button-primary button-block create-token-btn">${i18n('create_token')}</button>`;
h += '</div>';
// -- Phase B: result (shown once, after mint) --
h += '<div class="token-result" style="display:none;">';
h += `<div class="token-result-warning" style="margin-bottom: 12px; font-size: 13px; color: #b54708; background: #fffaeb; border: 1px solid #fedf89; border-radius: 6px; padding: 10px;">${i18n('token_shown_once_warning')}</div>`;
h += '<div style="display: flex; gap: 8px; margin-bottom: 12px;">';
h += '<input type="text" class="token-input" readonly value="" style="flex: 1; font-family: monospace; font-size: 13px;" />';
h += `<button class="button button-primary copy-token-btn">${i18n('copy')}</button>`;
h += '</div>';
h += '<div class="token-copied-msg form-success-msg" style="display: none; text-align: center;">';
h += i18n('token_copied');
h += `<div class="token-copied-msg form-success-msg" style="display: none; text-align: center; margin-bottom: 8px;">${i18n('token_copied')}</div>`;
h += `<div style="font-size: 12px; color: #666;">${i18n('token_manage_hint')} <a href="#" class="token-manage-sessions-link">${i18n('ui_manage_sessions')}</a>.</div>`;
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('auth_token'),
title: i18n('create_api_token'),
app: 'copy-token',
single_instance: true,
icon: null,
@@ -99,19 +126,71 @@ function UIWindowCopyToken (options = {}) {
...options.window_options,
});
$(el_window).find('.copy-token-btn').on('click', function () {
const $win = $(el_window);
const showError = (msg) => {
$win.find('.form-error-msg').text(msg).show();
};
const doCreate = async (label) => {
const $btn = $win.find('.create-token-btn');
const expiresIn = $win.find('.token-expiry-select').val() || null;
$win.find('.form-error-msg').hide();
$btn.addClass('disabled').prop('disabled', true);
try {
const token = await create_access_token({ label, expiresIn });
$win.find('.create-token-form').hide();
$win.find('.token-input').val(token);
$win.find('.token-result').show();
} catch ( e ) {
showError(e?.message ?? String(e));
$btn.removeClass('disabled').prop('disabled', false);
}
};
$win.find('.create-token-btn').on('click', function () {
const label = ($win.find('.token-label-input').val() || '').trim();
if ( ! label ) {
showError(i18n('token_label_required'));
$win.find('.token-label-input').focus();
return;
}
doCreate(label);
});
// Enter in the label field submits.
$win.find('.token-label-input').on('keydown', function (e) {
if ( e.key === 'Enter' ) {
e.preventDefault();
$win.find('.create-token-btn').trigger('click');
}
});
$win.find('.copy-token-btn').on('click', function () {
const $btn = $(this);
navigator.clipboard.writeText(window.auth_token).then(() => {
$(el_window).find('.token-copied-msg').fadeIn();
navigator.clipboard.writeText($win.find('.token-input').val()).then(() => {
$win.find('.token-copied-msg').fadeIn();
$btn.text(i18n('token_copied'));
setTimeout(() => {
$(el_window).find('.token-copied-msg').fadeOut();
$win.find('.token-copied-msg').fadeOut();
$btn.text(i18n('copy'));
}, 2000);
});
});
$(el_window).on('close', () => {
$win.find('.token-manage-sessions-link').on('click', function (e) {
e.preventDefault();
UIWindowManageSessions({
window_options: {
parent_uuid: $win.attr('data-element_uuid'),
backdrop: true,
close_on_backdrop_click: true,
stay_on_top: true,
},
});
});
$win.on('close', () => {
resolve();
});
});
@@ -0,0 +1,70 @@
/**
* 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/>.
*/
/**
* Mint a named, revocable **full-API-access** token for the current user.
*
* Unlike the raw GUI/session token this replaces, the returned access token
* can do everything the user can via the API (filesystem, drivers, KV, AI,
* apps, workers) but is rejected by the account-management endpoints
* (change password/email/username, 2FA, session-cookie sync, minting more
* tokens) — those gate on actor type, and an access-token actor never passes.
*
* The token is listed and revocable under Settings → Security → Manage
* sessions (it lands there as a labelled `access_token` session row).
*
* @param {object} [opts]
* @param {string|null} [opts.label] User-facing name (shown in manage-sessions).
* @param {string|null} [opts.expiresIn] jsonwebtoken duration, e.g. '30d'; null = never.
* @returns {Promise<string>} the signed access token
*/
const create_access_token = async ({ label = null, expiresIn = null } = {}) => {
const body = {
// Sentinel grant — see FULL_API_ACCESS in the backend auth types.
permissions: ['full-api-access'],
};
if ( label ) body.label = label;
if ( expiresIn ) body.expiresIn = expiresIn;
const resp = await fetch(`${window.api_origin}/auth/create-access-token`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken || window.auth_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if ( ! resp.ok ) {
let message;
try {
message = (await resp.clone().json())?.message;
} catch {
try {
message = await resp.text();
} catch { /* ignore */ }
}
throw new Error(message || `Failed to create token (${resp.status})`);
}
const { token } = await resp.json();
return token;
};
export default create_access_token;
+20
View File
@@ -613,6 +613,26 @@ const en = {
copy_token_message: 'Your authentication token is shown below. Keep it secret \u2014 anyone with this token can access your account.',
copy_token_description: 'View and copy your authentication token',
// API tokens (named, revocable, full API access \u2014 no account management)
api_token: 'API Token',
api_token_description: 'Create a named token for scripts, CLIs, and integrations',
create_api_token: 'Create API Token',
create_token: 'Create token',
create_token_message: 'Create a named token to use the Puter API from scripts, CLIs, agents, and integrations. It can do anything you can through the API, but cannot change your password or email, or delete your account.',
token_label: 'Label',
token_label_placeholder: 'e.g. My CLI, GitHub Actions',
token_label_required: 'Please enter a label for this token.',
token_label_external_app: 'External app',
token_expiry: 'Expiration',
token_expiry_never: 'Never',
token_expiry_7d: '7 days',
token_expiry_30d: '30 days',
token_expiry_90d: '90 days',
token_shown_once_warning: 'Copy this token now \u2014 it won\u2019t be shown again. Keep it secret; anyone with it can access your account\u2019s data through the API.',
token_manage_hint: 'You can revoke this token any time under',
shared_api_token: 'A restricted API token',
shared_api_token_note: 'The app can use the Puter API on your behalf, but cannot change your password or email, or delete your account. You can revoke it any time under Settings \u2192 Security \u2192 Manage sessions.',
// AuthMe dialog
authorization_required: 'Authorization Required',
external_site_auth_request: 'An app is requesting access to your account.',
+35 -2
View File
@@ -35,6 +35,7 @@ import UIWindowSessionList from './UI/UIWindowSessionList.js';
import UIWindowSignup from './UI/UIWindowSignup.js';
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
import { PROCESS_RUNNING } from './definitions.js';
import create_access_token from './helpers/create_access_token.js';
import item_icon from './helpers/item_icon.js';
import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js';
import update_mouse_position from './helpers/update_mouse_position.js';
@@ -855,8 +856,24 @@ window.initgui = async function (options) {
redirect_url: redirectURL,
});
if ( approved ) {
// Hand the app a named, revocable full-API-access
// token instead of the raw GUI/session token: it can
// use the whole API but can't manage the account.
let host = '';
try { host = new URL(redirectURL).host; } catch ( e ) { /* ignore */ }
let token;
try {
token = await create_access_token({
label: host
? `${i18n('token_label_external_app')} (${host})`
: i18n('token_label_external_app'),
});
} catch ( e ) {
await UIAlert({ message: e?.message ?? String(e) });
return;
}
const url = new URL(redirectURL);
url.searchParams.set('token', window.auth_token);
url.searchParams.set('token', token);
window.location.href = url.href;
return;
}
@@ -1412,8 +1429,24 @@ window.initgui = async function (options) {
redirect_url: redirectURL,
});
if ( approved ) {
// Hand the app a named, revocable full-API-access token
// instead of the raw GUI/session token: it can use the
// whole API but can't manage the account.
let host = '';
try { host = new URL(redirectURL).host; } catch ( e ) { /* ignore */ }
let token;
try {
token = await create_access_token({
label: host
? `${i18n('token_label_external_app')} (${host})`
: i18n('token_label_external_app'),
});
} catch ( e ) {
await UIAlert({ message: e?.message ?? String(e) });
return;
}
const url = new URL(redirectURL);
url.searchParams.set('token', window.auth_token);
url.searchParams.set('token', token);
window.location.href = url.href;
return;
}
+18 -3
View File
@@ -145,7 +145,10 @@ function formatPuterError(err) {
function jsonResponse(body) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
// Per-user MCP results must never be cached by any CDN in front of the
// worker. (POST isn't cached by default, but be explicit — a misconfigured
// edge cache that ignores method/query could otherwise leak responses.)
headers: { 'content-type': 'application/json', 'Cache-Control': 'no-store' },
});
}
@@ -166,6 +169,7 @@ async function mcpPost(event) {
headers: {
'content-type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
},
},
@@ -193,9 +197,12 @@ async function mcpPost(event) {
return jsonResponse(response);
}
// Discovery / health.
// Discovery / health. This is the ONLY endpoint safe to cache: it's public,
// identical for every caller, and hammered by clients polling for the tool list.
// It's explicitly marked cacheable so a "respect-origin" CDN policy caches this
// and nothing else (every other response sends no-store).
function mcpInfo() {
return {
const body = {
name: 'puter-mcp',
description:
'MCP server for Puter filesystem, static website hosting, serverless worker, and app registration operations. POST JSON-RPC to ' +
@@ -203,6 +210,14 @@ function mcpInfo() {
transport: 'streamable-http',
tools: listTools().map((t) => t.name),
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'content-type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
},
});
}
/** Attach the MCP routes to the (already-initialized) router. */
+12 -2
View File
@@ -87,17 +87,27 @@ async function sha256b64url(str) {
// ---- response helpers ------------------------------------------------------
// Every OAuth response is per-request (sealed flow/code blobs, one-time auth
// codes, tokens) and MUST NOT be cached. Without this, a caching layer in front
// of the worker (e.g. the puter.com zone) can replay one user's /authorize
// redirect — and thus their stale callback port / code — to everyone.
// RFC 6749 §5.1 also mandates no-store on the token endpoint.
const NO_STORE = {
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
Pragma: 'no-cache',
};
function json(status, obj) {
return new Response(JSON.stringify(obj), {
status,
headers: { 'content-type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'content-type': 'application/json', 'Access-Control-Allow-Origin': '*', ...NO_STORE },
});
}
function redirect(location) {
return new Response(null, {
status: 302,
headers: { Location: location, 'Access-Control-Allow-Origin': '*' },
headers: { Location: location, 'Access-Control-Allow-Origin': '*', ...NO_STORE },
});
}
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more