Compare commits

..
63 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
Sourabh Sharma 15134c99bf Fix mkdir dedupeName for existing directories (#3215) 2026-06-07 12:49:00 -07:00
Daniel Salazar 8262e3193e perf: don't batch get app stats (#3219) 2026-06-06 18:28:59 -07:00
Reynaldi Chernando 98a21c4ac0 More workers docs improvement (#3218)
* More workers docs improvement

* Add mention of workers alongside mention of user scope
2026-06-06 01:27:24 +07:00
ProgrammerIn-wonderland c52eb1b903 Ns/bug fixes 060426 (#3214)
* Harden backend auth

* Puter.js auth hardening
2026-06-05 13:55:23 -04:00
mohit_parmar_007 d6a71e50bb fix: remove unnecessary cache console.log statements (#3216) 2026-06-05 10:01:59 -07:00
Reynaldi Chernando a4b6ee0dd2 [PUT-1090] Improve worker docs (#3217)
* Improve worker docs

* refine
2026-06-05 21:54:56 +07:00
Daniel Salazar bb61103080 feat: add apps api into mcp (#3213) 2026-06-04 17:19:53 -07:00
Daniel Salazar 9bbc76b2dd drop metering alerts to low severity (#3212) 2026-06-04 14:01:44 -07:00
Daniel Salazar 918aba9c80 fix: reduce alarms further (#3211)
* feat; update bug-bounty

* fix: reduce alarms further
2026-06-04 10:43:50 -07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 3ceba9e3b3 Minor fix deployment docs (#3210)
* Minor fix deployment docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-04 23:48:25 +07:00
Daniel Salazar a480679a2d vis: make metering service alarm only go off on actual abuse (#3208) 2026-06-03 22:45:18 -07:00
Daniel Salazar e99ae40e93 chore: bump puter-js (#3207) 2026-06-03 18:59:07 -07:00
Daniel Salazar 3cd50fbaf3 move the token migration to root (#3206) 2026-06-03 18:54:22 -07:00
ProgrammerIn-wonderland 60dd1453d4 Fix issues (#3205) 2026-06-03 21:19:33 -04:00
ProgrammerIn-wonderland 07781cd681 allow overriding origin from workers.dev on mcp server (#3204) 2026-06-03 17:48:37 -04:00
ProgrammerIn-wonderland 2387d8dcc9 MCP whoami (#3203) 2026-06-03 17:13:13 -04:00
Reynaldi Chernando 723608f4db Add new puter cli (#3199)
* Add new cli

* update package lock

* fix readme and license
2026-06-04 01:51:31 +07:00
Reynaldi Chernando bd1dcde7b0 Add info for puterjs deployments (#3202)
* Add info for puterjs deployments

* update external links

* hide github actions method
2026-06-04 01:25:57 +07:00
ProgrammerIn-wonderland 5b8e41e66d MCP Server (#3197)
* beginnings of mcp

* oauth support

* add workers and better prompt engineering
2026-06-03 12:23:06 -04:00
Nariman Jelveh acc4796b99 Allow Cache-Control and Pragma headers (#3198) 2026-06-03 14:30:33 +02:00
jelveh 106e85d6ca Force revalidation on FileSystem reads 2026-06-03 14:15:22 +02:00
ProgrammerIn-wonderland 0ffc32f19e add whitelist for signed batch uploads (#3196) 2026-06-02 21:30:34 -04:00
Daniel Salazar d897cc5dd0 refactor: move subscriptions to webhook system (#3193)
* fix: dont sign write urls if they don't have write perm

* wip: new subscriptions

* refactor: move subscriptions to
2026-06-02 17:58:56 -07:00
Daniel Salazar 6d2f277ce2 fix: some error handling (#3190) 2026-05-31 18:58:27 -07:00
ProgrammerIn-wonderland 86f80b29c0 tighten RAO policy (#3188) 2026-05-29 20:52:21 -04:00
Daniel SalazarandProgrammerIn-wonderland 39243735e8 feat (put-1049): worker types (#3185)
* feat (put-1049): worket types

* Implement changes to worker types

---------

Co-authored-by: ProgrammerIn-wonderland <[email protected]>
2026-05-29 20:51:57 -04:00
ProgrammerIn-wonderland b3ff9d74b7 add browserjs to taskbar and RecommendedAppService (#3189) 2026-05-29 20:32:53 -04:00
ProgrammerIn-wonderland 7b7604440f Add serverless only filtering for togetherai chat models (#3187) 2026-05-29 14:33:55 -04:00
ProgrammerIn-wonderland 382d2b3c14 add minimax provider (#3171)
* add minimax provider
2026-05-29 13:36:10 -04:00
Anurag Pappula eda5379c17 fix(docs/search): handle search index loading and failure states (#3184)
* fix(docs/search): show loading state and replay query when index isn't ready

Previously performSearch rendered "No results found" while the
MiniSearch index was still loading, which is misleading on slow
networks. Track the last query as pendingQuery and replay it from
fetchSearchIndex once the index is ready; render a distinct
"Failed to load search index" message on fetch error so the
loading message doesn't persist indefinitely.

Fixes #3180.

* fix(docs/search): close stale-replay race and consolidate status messages

Drop pendingQuery synchronously in the input handler when the query
falls below the minimum length, so a fetchSearchIndex that resolves
within the 300ms debounce window after the user clears the field can
no longer replay the cleared query into an empty input.

Move the idle, loading, and failed copy into a single
renderSearchStatus(kind) helper so the four .search-no-results
renderings can only drift in one place.
2026-05-29 16:00:59 +07:00
Ron HernausandDaniel Salazar d2fee51844 Support PostgreSQL database backend (#3167)
* feat(database): add postgres database client

Adds the PostgreSQL database client, native bootstrap migration, SQL preparation helpers, and database config/factory wiring.\n\nRefs #3165.

* feat(database): make backend queries postgres-aware

Updates runtime SQL call sites for database-specific booleans, identifiers, insert-ignore, upserts, JSON extraction, intervals, and Postgres insert ids.\n\nRefs #3165.

* test(database): cover postgres client behavior

Adds unit coverage for SQL preparation, factory selection, write-result mapping, and transaction rollback/commit ordering, plus an env-gated PostgreSQL integration flow.\n\nRefs #3165.

* docs(self-hosting): document postgres database setup

Adds PostgreSQL configuration examples and migration path guidance for self-hosted deployments.\n\nRefs #3165.

* fix: harden postgres oidc tests

* fix(postgres): normalize query results and SQL prep

* fix(user): preserve normalized cache booleans

* test(postgres): run integration coverage with pgmock

* tests: add way to run all tests with postgres though slow

Also adding note that postgres is not in active use so might not work out the box

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-05-28 14:12:17 -07:00
Daniel Salazar 8ac4bad196 fix: readUrl perm check (#3183) 2026-05-28 11:44:16 -07:00
Reynaldi Chernando f140d7221c Add opus 4.8 (#3182)
* Add opus 4.8

* update alias

* handling opus 48 omit temperature
2026-05-28 13:27:46 -04:00
Daniel Salazar 631f0bda1b chore:deploy latest puter.js (#3181) 2026-05-28 09:58:33 -07:00
Reynaldi Chernando 1121314582 Docs search improvement (#3179)
* Init search improvement

* Add fuzzy implementation

* update package lock

* package lock matched with runner 24.16.0
2026-05-28 23:11:34 +07:00
204 changed files with 18523 additions and 3876 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
+2
View File
@@ -70,3 +70,5 @@ coverage/
undefined
servers.json
config.*.json
volatile/
+34 -1
View File
@@ -17,18 +17,51 @@ Out-of-scope:
* Denial of Service (DoS), spam, or volumetric attacks.
* Physical security issues.
## Known Non-Issues (Please Check Before Submitting)
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
* **App metadata or app user-count "leaks".** This information is currently public by design.
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
## Rules of Engagement
To participate, you must:
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable.
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
Reports that do not meet these guidelines may not be eligible for a reward.
## Proof of Reproduction
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
* The **observed** result versus the **expected** result.
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
* For server-side bugs: a runnable proof-of-concept.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
## Reporting Process
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
+26 -4
View File
@@ -159,17 +159,30 @@
// ── Database ────────────────────────────────────────────────────────
"database": {
// `sqlite` for single-node / dev; `mysql` for self-host with MariaDB.
// `sqlite` for single-node/dev, `mysql` for MariaDB/MySQL,
// or `postgres` for PostgreSQL.
//
// NOTE: `postgres` support is a community contribution and is not
// exercised by Puter.com production. It boots, passes its own
// integration tests against pgmock, and runs the common user/app/
// fsentry/session/permission/OIDC flows — but less-traveled code
// paths may surface MySQL/SQLite-isms that haven't been ported yet.
// Expect rough edges and please file issues if you hit one. For
// production self-hosting today, `mysql` (MariaDB) and `sqlite` are
// the supported defaults.
"engine": "sqlite",
// sqlite — file path on disk
"path": "volatile/runtime/puter-database.sqlite",
"targetVersion": 0,
// mysql — connection details
// mysql/postgres — connection details. PostgreSQL defaults to port
// 5432 when `engine` is `postgres`; MySQL/MariaDB normally use 3306.
"host": "",
"port": 3306,
"user": "",
"password": "",
"database": "",
// postgres may also use a URL instead of host/user/password fields:
// "connectionString": "postgres://puter:secret@localhost:5432/puter",
// Optional read-replica pool. Reads route here when populated.
"replica": {
"host": "",
@@ -178,9 +191,10 @@
"password": "",
"database": ""
}
// mysql self-host bootstrap: set `migrationPaths` to apply the bundled
// schema on first boot. Idempotent — safe to leave on.
// mysql/postgres self-host bootstrap: set `migrationPaths` to apply the
// bundled schema on first boot. Idempotent — safe to leave on.
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
// "migrationPaths": ["./src/backend/clients/database/migrations/postgres"]
},
// ── DynamoDB (KV store) ─────────────────────────────────────────────
@@ -246,12 +260,20 @@
// ─ Chat / completion ─
"claude": { "apiKey": "" },
"openai-completion": { "apiKey": "" },
"azure-openai": {
"apiKey": "",
"apiURL": ""
},
"gemini": { "apiKey": "" },
"groq": { "apiKey": "" },
"deepseek": { "apiKey": "" },
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
"minimax": {
"apiKey": "",
"apiBaseUrl": "https://api.minimax.io/v1"
},
"openrouter": {
"apiKey": "",
"apiBaseUrl": "https://openrouter.ai/api/v1"
+21 -1
View File
@@ -136,7 +136,7 @@ Why these knobs:
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
@@ -232,6 +232,26 @@ Change it in Settings after first login.
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
### PostgreSQL database
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
```json
"database": {
"engine": "postgres",
"host": "postgres",
"port": 5432,
"user": "puter",
"password": "...",
"database": "puter",
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/postgres"]
}
```
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
### Email (SMTP)
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
+1 -1
View File
@@ -84,7 +84,7 @@ export const handleAppTelemetryUsers = async (
const users = await clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN user u ON p.user_id = u.id
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
+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,
);
+1834 -2603
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -45,6 +45,7 @@
},
"scripts": {
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run build:ts",
@@ -0,0 +1,50 @@
/**
* 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/>.
*/
/**
* Optional ClickHouse client.
*
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
* stats read path (open / unique-user counts) falls back to the primary SQL
* database. `this.clients.clickhouse` is therefore typed as optional and is
* `undefined` in default and self-hosted setups; callers MUST branch on its
* presence and fall back to SQL.
*
* A production deployment can register a real ClickHouse client via an
* extension (`extension.registerClient('clickhouse', client)`) to offload the
* analytics queries off the primary database and keep the stats path fast at
* scale. When registered, the instance flows into `this.clients.clickhouse`
* everywhere it's typed.
*
* Only the surface the stats path actually consumes is declared here. Extend
* this interface (don't widen to `any`) when a new query shape is needed.
*/
export interface ClickhouseQueryResult {
json<T = Record<string, unknown>>(): Promise<T[]>;
}
export interface ClickhouseQueryParams {
query: string;
query_params?: Record<string, unknown>;
format?: string;
}
export interface ClickhouseClient {
query(params: ClickhouseQueryParams): Promise<ClickhouseQueryResult>;
}
+90 -3
View File
@@ -31,6 +31,8 @@ export interface BatchEntry {
values: unknown[];
}
type SqlJsonPath = readonly [string, ...string[]];
/**
* Base database client. Subclasses must override every method that throws here.
*
@@ -101,9 +103,10 @@ export class AbstractDatabaseClient extends PuterClient {
const cols = Object.keys(data);
const values = Object.values(data);
const sql =
`INSERT INTO \`${tableName}\` ` +
`(${cols.map((c) => `\`${c}\``).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})`;
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})` +
this.returningIdClause();
return this.write(sql, values);
}
@@ -161,4 +164,88 @@ export class AbstractDatabaseClient extends PuterClient {
}
return choices.otherwise as T;
}
quoteIdentifier(identifier: string): string {
return identifier
.split('.')
.map((part) => {
if (part === '*') return part;
return `\`${part.replaceAll('`', '``')}\``;
})
.join('.');
}
booleanLiteral(value: boolean): string {
return value ? '1' : '0';
}
booleanValue(value: boolean): boolean | 0 | 1 {
return value ? 1 : 0;
}
insertIgnoreInto(tableName: string): string {
const table = this.quoteIdentifier(tableName);
return this.case({
sqlite: `INSERT OR IGNORE INTO ${table}`,
postgres: `INSERT INTO ${table}`,
otherwise: `INSERT IGNORE INTO ${table}`,
});
}
insertIgnoreSuffix(): string {
return this.case({
postgres: ' ON CONFLICT DO NOTHING',
otherwise: '',
});
}
upsertClause(
conflictColumns: readonly string[],
updateColumns: readonly string[],
): string {
if (updateColumns.length === 0) {
throw new Error('upsertClause requires at least one update column');
}
const updateList = updateColumns
.map((column) => `${this.quoteIdentifier(column)} = ?`)
.join(', ');
return this.case({
mysql: `ON DUPLICATE KEY UPDATE ${updateList}`,
otherwise: `ON CONFLICT(${conflictColumns
.map((column) => this.quoteIdentifier(column))
.join(', ')}) DO UPDATE SET ${updateList}`,
});
}
jsonTextExtract(jsonExpression: string, path: SqlJsonPath): string {
const sqlitePath = `$${path.map((part) => `.${part}`).join('')}`;
return this.case({
sqlite: `json_extract(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)})`,
mysql: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
postgres: `${jsonExpression} #>> ARRAY[${path
.map((part) => this.sqlStringLiteral(part))
.join(', ')}]`,
otherwise: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
});
}
nullCoalesce(...expressions: readonly string[]): string {
if (expressions.length === 0) {
throw new Error('nullCoalesce requires at least one expression');
}
return `COALESCE(${expressions.join(', ')})`;
}
returningIdClause(): string {
return this.case({
postgres: ' RETURNING id',
otherwise: '',
});
}
protected sqlStringLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
}
@@ -23,6 +23,7 @@ import { createPool, type Pool } from 'mysql2';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { SQLBatcher } from './SQLBatcher.js';
import { splitMysqlStatements } from './splitMysqlStatements.js';
import { compareMigrationFilenames } from './migrationFilenames.js';
import type { IConfig } from '../../types';
const RETRIABLE_ERROR_CODES = new Set([
@@ -44,36 +45,7 @@ const RETRIABLE_ERROR_MESSAGES = [
'ETIMEDOUT',
];
/**
* Comparator for MySQL migration filenames.
*
* Existing files are named `mysql_mig_<N>.sql` with unpadded N, so a
* plain lexical sort puts `mysql_mig_10.sql` BEFORE `mysql_mig_2.sql`.
* Pull the trailing integer out and sort numerically. Anything that
* doesn't match the `_<digits>.sql` shape (one-off, vendor dump) falls
* back to lexical comparison so the order stays deterministic, and
* unmatched names sort *after* numbered files so future numbered
* migrations don't get interleaved into a one-off's namespace.
*
* Exported only for the unit test — the production caller is the
* `runMigrations()` loop inside this file.
*/
export const compareMigrationFilenames = (a: string, b: string): number => {
const numericIndex = (name: string): number => {
const m = /_(\d+)\.sql$/.exec(name);
return m ? Number.parseInt(m[1], 10) : Number.NaN;
};
const na = numericIndex(a);
const nb = numericIndex(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
if (na !== nb) return na - nb;
} else if (Number.isFinite(na)) {
return -1;
} else if (Number.isFinite(nb)) {
return 1;
}
return a.localeCompare(b);
};
export { compareMigrationFilenames };
type PoolConfig = Parameters<typeof createPool>[0];
@@ -0,0 +1,328 @@
/**
* 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 { Pool } from 'pg';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { IConfig } from '../../types';
import type { PuterServer } from '../../server';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import {
createPgMockPostgresDatabaseClient,
POSTGRES_TEST_MIGRATIONS_PATH,
setupTestServer,
} from '../../testUtil.js';
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
const postgresUrl = process.env.PUTER_TEST_POSTGRES_URL;
const postgresMigrationsPath = POSTGRES_TEST_MIGRATIONS_PATH;
const postgresTestSchemaPattern = /^puter_test_[a-f0-9]{32}$/u;
const postgresIntegrationTimeoutMs = 180_000;
let postgresTestSchema: string | undefined;
let postgresTestUrl: string | undefined;
const postgresConfig = (overrides: Partial<IConfig> = {}): IConfig => {
if (postgresUrl && !postgresTestUrl) {
throw new Error('Postgres test schema was not initialized');
}
const { database: databaseOverrides, ...rootOverrides } = overrides;
const database = postgresUrl
? {
engine: 'postgres' as const,
inMemory: false,
connectionString: postgresTestUrl,
migrationPaths: [postgresMigrationsPath],
}
: {
engine: 'postgres' as const,
inMemory: true,
migrationPaths: [postgresMigrationsPath],
};
return {
port: 0,
extensions: [],
database: { ...database, ...(databaseOverrides ?? {}) },
...rootOverrides,
};
};
const quoteTestSchemaIdentifier = (schema: string): string => {
if (!postgresTestSchemaPattern.test(schema)) {
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
}
return `"${schema}"`;
};
const postgresConnectionStringForSchema = (
connectionString: string,
schema: string,
): string => {
if (!postgresTestSchemaPattern.test(schema)) {
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
}
const url = new URL(connectionString);
url.searchParams.set('options', `-c search_path=${schema}`);
return url.toString();
};
const createPostgresTestSchema = async (): Promise<void> => {
if (!postgresUrl) return;
const schema = `puter_test_${uuidv4().replaceAll('-', '')}`;
const pool = new Pool({ connectionString: postgresUrl });
try {
await pool.query(`CREATE SCHEMA ${quoteTestSchemaIdentifier(schema)}`);
postgresTestSchema = schema;
postgresTestUrl = postgresConnectionStringForSchema(
postgresUrl,
schema,
);
} finally {
await pool.end();
}
};
const dropPostgresTestSchema = async (): Promise<void> => {
if (!postgresUrl || !postgresTestSchema) return;
const schema = postgresTestSchema;
postgresTestSchema = undefined;
postgresTestUrl = undefined;
const pool = new Pool({ connectionString: postgresUrl });
try {
await pool.query(
`DROP SCHEMA IF EXISTS ${quoteTestSchemaIdentifier(schema)} CASCADE`,
);
} finally {
await pool.end();
}
};
describe('PostgresDatabaseClient integration', () => {
let server: PuterServer | undefined;
beforeEach(async () => {
await createPostgresTestSchema();
});
afterEach(async () => {
try {
await server?.shutdown();
} finally {
server = undefined;
await dropPostgresTestSchema();
}
});
it(
'applies the native migrations idempotently to an empty database',
async () => {
const config = postgresConfig();
const pgMockClient = postgresUrl
? undefined
: await createPgMockPostgresDatabaseClient(config);
try {
const firstClient =
pgMockClient?.client ?? new PostgresDatabaseClient(config);
try {
await firstClient.onServerStart();
} finally {
await firstClient.onServerShutdown();
}
const secondClient =
pgMockClient?.createClient() ??
new PostgresDatabaseClient(config);
try {
await secondClient.onServerStart();
const [systemUser] = await secondClient.read(
'SELECT `id`, `username` FROM `user` WHERE `username` = ?',
['system'],
);
const [devCenter] = await secondClient.read(
'SELECT `name`, `index_url` FROM `apps` WHERE `name` = ?',
['dev-center'],
);
expect(systemUser).toMatchObject({
id: 1,
username: 'system',
});
expect(devCenter).toMatchObject({
name: 'dev-center',
index_url:
'https://builtins.namespaces.puter.com/dev-center',
});
} finally {
await secondClient.onServerShutdown();
}
} finally {
pgMockClient?.destroy();
}
},
postgresIntegrationTimeoutMs,
);
it('starts the server and exercises user, app, fsentry, permission, and session flows', async () => {
const userStorageAllowance = 123_456_789;
server = await setupTestServer(
postgresConfig({
no_default_user: false,
is_storage_limited: true,
}),
);
const admin = await server.stores.user.getByUsername('admin');
expect(admin?.username).toBe('admin');
const username = `pg-${uuidv4().slice(0, 8)}`;
const createdUser = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: userStorageAllowance,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
createdUser,
);
const user = await server.stores.user.getById(createdUser.id);
if (!user) throw new Error('created user was not readable');
expect(user.username).toBe(username);
await expect(
server.stores.fsEntry.getUserStorageAllowance(user.id),
).resolves.toMatchObject({
max: userStorageAllowance,
});
const otherUsername = `pg-other-${uuidv4().slice(0, 8)}`;
const otherUser = await server.stores.user.create({
username: otherUsername,
uuid: uuidv4(),
password: null,
email: `${otherUsername}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const authResult = await server.services.auth.createSessionToken(
user,
{
ip: '127.0.0.1',
user_agent: 'postgres-integration-test',
},
);
const authenticated =
await server.services.auth.authenticateFromToken(authResult.token);
expect(authenticated?.user?.id).toBe(user?.id);
const devCenter = await server.stores.app.getByName('dev-center');
if (!devCenter) throw new Error('dev-center app was not seeded');
expect(devCenter.name).toBe('dev-center');
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
if (!documents) throw new Error('Documents directory was not created');
expect(documents.isDir).toBe(true);
const folder = await server.stores.fsEntry.createNonFileEntry({
userId: user.id,
parent: documents,
name: 'postgres-folder',
kind: 'directory',
});
const renamedFolder = await server.stores.fsEntry.updateEntry(
folder.uuid,
{
name: 'postgres-folder-renamed',
path: `/${username}/Documents/postgres-folder-renamed`,
},
);
expect(renamedFolder.name).toBe('postgres-folder-renamed');
await server.stores.permission.upsertUserAppPerm(
user.id,
Number(devCenter.id),
'driver:postgres-integration',
{ ok: true },
);
await expect(
server.stores.permission.hasUserAppPerm(
user.id,
Number(devCenter.id),
'driver:postgres-integration',
),
).resolves.toBe(true);
await server.stores.oidc.link(
user.id,
'postgres-integration-test',
'subject-1',
null,
);
await expect(
server.stores.oidc.link(
user.id,
'postgres-integration-test',
'subject-1',
null,
),
).resolves.toBeUndefined();
await expect(
server.stores.oidc.link(
otherUser.id,
'postgres-integration-test',
'subject-1',
null,
),
).rejects.toMatchObject({
statusCode: 409,
legacyCode: 'conflict',
});
const session = await server.stores.session.create(user.id, {
meta: { source: 'postgres-integration-test' },
});
const activeSession = await server.stores.session.getByUuid(
session.uuid,
);
expect(activeSession?.uuid).toBe(session.uuid);
const workerName = `worker-${uuidv4().slice(0, 8)}`;
const workerSession = await server.stores.session.getOrCreateWorker(
user.id,
{ workerName },
);
expect(workerSession?.kind).toBe('worker');
expect(workerSession?.meta?.worker_name).toBe(workerName);
await server.stores.session.removeByUuid(session.uuid);
await expect(
server.stores.session.getByUuid(session.uuid),
).resolves.toBeNull();
}, postgresIntegrationTimeoutMs);
});
@@ -0,0 +1,360 @@
/**
* 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 { FieldDef, QueryResult } from 'pg';
import { describe, expect, it } from 'vitest';
import type { IConfig } from '../../types';
import { DatabaseClientFactory } from './index.js';
import {
mapPostgresWriteResult,
PostgresDatabaseClient,
type PostgresPool,
type PostgresPoolClient,
} from './PostgresDatabaseClient.js';
type QueryCall = {
text: string;
values?: unknown[];
};
const postgresConfig = (): IConfig => ({
port: 0,
extensions: [],
database: {
engine: 'postgres',
migrationPaths: [],
},
});
const postgresReplicaConfig = (): IConfig => ({
port: 0,
extensions: [],
database: {
engine: 'postgres',
migrationPaths: [],
replica: {},
},
});
const field = (name: string, dataTypeID: number): FieldDef => ({
name,
tableID: 0,
columnID: 0,
dataTypeID,
dataTypeSize: -1,
dataTypeModifier: -1,
format: 'text',
});
const int8Field = (name: string): FieldDef => field(name, 20);
const textField = (name: string): FieldDef => field(name, 25);
const queryResult = (
rows: Record<string, unknown>[] = [],
rowCount = rows.length,
fields: FieldDef[] = [],
): QueryResult<Record<string, unknown>> => ({
command: '',
fields,
oid: 0,
rowCount,
rows,
});
class RecordingPoolClient implements PostgresPoolClient {
readonly calls: QueryCall[] = [];
released = false;
constructor(private readonly failOnText?: string) {}
async query(
text: string,
values?: unknown[],
): Promise<QueryResult<Record<string, unknown>>> {
this.calls.push({ text, values });
if (this.failOnText && text.includes(this.failOnText)) {
throw new Error(`forced query failure: ${text}`);
}
return queryResult([], text === 'ROLLBACK' ? 0 : 1);
}
release(): void {
this.released = true;
}
}
class RecordingPool implements PostgresPool {
readonly calls: QueryCall[] = [];
constructor(
private readonly client: RecordingPoolClient =
new RecordingPoolClient(),
private readonly nextResult: QueryResult<Record<string, unknown>> =
queryResult(),
) {}
async query(
text: string,
values?: unknown[],
): Promise<QueryResult<Record<string, unknown>>> {
this.calls.push({ text, values });
if (text === 'SELECT 1') return queryResult([{ ok: 1 }], 1);
return this.nextResult;
}
async connect(): Promise<PostgresPoolClient> {
return this.client;
}
async end(): Promise<void> {}
}
describe('PostgresDatabaseClient', () => {
it('is selected by the database factory', () => {
const client = new DatabaseClientFactory(postgresConfig());
expect(client).toBeInstanceOf(PostgresDatabaseClient);
});
it('maps pg write results to the shared WriteResult shape', () => {
expect(
mapPostgresWriteResult(queryResult([{ id: '42' }], 1)),
).toEqual({
insertId: 42,
affectedRows: 1,
anyRowsAffected: true,
});
expect(mapPostgresWriteResult(queryResult([], 0))).toEqual({
insertId: 0,
affectedRows: 0,
anyRowsAffected: false,
});
});
it('prepares write SQL at the pg boundary', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ id: '7' }], 1),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
const result = await client.write(
'INSERT INTO `apps` (`name`) VALUES (?) RETURNING id',
['editor'],
);
expect(result.insertId).toBe(7);
expect(pool.calls.at(-1)).toEqual({
text: 'INSERT INTO "apps" ("name") VALUES ($1) RETURNING id',
values: ['editor'],
});
});
it('normalizes int8 fields on read and primary read rows', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult(
[
{
uuid: 'session-1',
created_at: '1710000000',
last_activity: '1710000001',
expires_at: '1710000002',
revoked_at: null,
},
],
1,
[
textField('uuid'),
int8Field('created_at'),
int8Field('last_activity'),
int8Field('expires_at'),
int8Field('revoked_at'),
],
),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
await expect(client.read('SELECT * FROM `sessions`')).resolves.toEqual(
[
{
uuid: 'session-1',
created_at: 1710000000,
last_activity: 1710000001,
expires_at: 1710000002,
revoked_at: null,
},
],
);
await expect(client.pread('SELECT * FROM `sessions`')).resolves.toEqual(
[
{
uuid: 'session-1',
created_at: 1710000000,
last_activity: 1710000001,
expires_at: 1710000002,
revoked_at: null,
},
],
);
});
it('rejects unsafe int8 values instead of losing precision', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult(
[{ id: '9007199254740992' }],
1,
[int8Field('id')],
),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
await expect(client.read('SELECT `id` FROM `sessions`')).rejects.toThrow(
'safe integer',
);
});
it('normalizes tryHardRead rows returned by a replica', async () => {
const primaryPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '1' }], 1, [
int8Field('created_at'),
]),
);
const replicaPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '2' }], 1, [
int8Field('created_at'),
]),
);
const pools = [primaryPool, replicaPool];
let nextPoolIndex = 0;
const client = new PostgresDatabaseClient(
postgresReplicaConfig(),
() => {
const pool = pools[nextPoolIndex];
nextPoolIndex += 1;
if (!pool) throw new Error('unexpected pool factory call');
return pool;
},
);
await client.onServerStart();
await expect(
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
).resolves.toEqual([{ created_at: 2 }]);
});
it('normalizes tryHardRead rows returned by primary fallback', async () => {
const primaryPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '3' }], 1, [
int8Field('created_at'),
]),
);
const replicaPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([], 0, [int8Field('created_at')]),
);
const pools = [primaryPool, replicaPool];
let nextPoolIndex = 0;
const client = new PostgresDatabaseClient(
postgresReplicaConfig(),
() => {
const pool = pools[nextPoolIndex];
nextPoolIndex += 1;
if (!pool) throw new Error('unexpected pool factory call');
return pool;
},
);
await client.onServerStart();
await expect(
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
).resolves.toEqual([{ created_at: 3 }]);
});
it('runs batch writes in order and commits', async () => {
const conn = new RecordingPoolClient();
const client = new PostgresDatabaseClient(
postgresConfig(),
() => new RecordingPool(conn),
);
await client.onServerStart();
await client.batchWrite([
{
statement:
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
values: ['ada', 1],
},
{
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
values: ['session-1'],
},
]);
expect(conn.calls).toEqual([
{ text: 'BEGIN', values: undefined },
{
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
values: ['ada', 1],
},
{
text: 'DELETE FROM "sessions" WHERE "uuid" = $1',
values: ['session-1'],
},
{ text: 'COMMIT', values: undefined },
]);
expect(conn.released).toBe(true);
});
it('rolls back and releases the pg connection when a batch write fails', async () => {
const conn = new RecordingPoolClient('UPDATE "user"');
const client = new PostgresDatabaseClient(
postgresConfig(),
() => new RecordingPool(conn),
);
await client.onServerStart();
await expect(
client.batchWrite([
{
statement:
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
values: ['ada', 1],
},
]),
).rejects.toThrow('forced query failure');
expect(conn.calls).toEqual([
{ text: 'BEGIN', values: undefined },
{
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
values: ['ada', 1],
},
{ text: 'ROLLBACK', values: undefined },
]);
expect(conn.released).toBe(true);
});
});
@@ -0,0 +1,390 @@
/**
* 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 { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { Pool, type PoolConfig, type QueryResult } from 'pg';
import {
AbstractDatabaseClient,
type BatchEntry,
type WriteResult,
} from './DatabaseClient';
import { compareMigrationFilenames } from './migrationFilenames.js';
import { preparePostgresSql } from './preparePostgresSql.js';
import { splitPostgresStatements } from './splitPostgresStatements.js';
import type { IConfig } from '../../types';
type PostgresEndpointConfig = {
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
connectionString?: string;
url?: string;
};
export interface PostgresQueryable {
query(query: string, values?: unknown[]): Promise<QueryResult>;
}
export interface PostgresPoolClient extends PostgresQueryable {
release(): void;
}
export interface PostgresPool extends PostgresQueryable {
connect(): Promise<PostgresPoolClient>;
end(): Promise<void>;
}
type PostgresPoolFactory = (poolConfig: PoolConfig) => PostgresPool;
enum Configuration {
SINGLE,
REPLICA,
}
const POSTGRES_INT8_OID = 20;
const INTEGER_TEXT_PATTERN = /^-?\d+$/u;
const normalizePostgresInt8 = (
value: unknown,
columnName: string,
): number | null | undefined => {
if (value === null || value === undefined) return value;
const parsed =
typeof value === 'bigint'
? Number(value)
: typeof value === 'number'
? value
: typeof value === 'string' && INTEGER_TEXT_PATTERN.test(value)
? Number(value)
: Number.NaN;
if (!Number.isSafeInteger(parsed)) {
throw new Error(
`[postgres] int8 column ${columnName} is outside JavaScript's safe integer range`,
);
}
return parsed;
};
const normalizePostgresRows = (
result: QueryResult,
): Record<string, unknown>[] => {
const int8Fields = result.fields.filter(
(field) => field.dataTypeID === POSTGRES_INT8_OID,
);
if (int8Fields.length === 0) {
return result.rows as Record<string, unknown>[];
}
return result.rows.map((row) => {
const normalized: Record<string, unknown> = { ...row };
for (const field of int8Fields) {
normalized[field.name] = normalizePostgresInt8(
normalized[field.name],
field.name,
);
}
return normalized;
});
};
export const mapPostgresWriteResult = (result: QueryResult): WriteResult => {
const affectedRows = result.rowCount ?? 0;
const rowId = result.rows[0]?.id;
const insertId =
typeof rowId === 'bigint'
? rowId
: typeof rowId === 'number'
? rowId
: typeof rowId === 'string' && rowId !== ''
? Number(rowId)
: 0;
const normalizedInsertId =
typeof insertId === 'number' && Number.isNaN(insertId) ? 0 : insertId;
return {
insertId: normalizedInsertId,
affectedRows,
anyRowsAffected: affectedRows > 0,
};
};
export class PostgresDatabaseClient extends AbstractDatabaseClient {
override readonly engineName = 'postgres';
private primaryPool!: PostgresPool;
private replicaPool!: PostgresPool;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: IConfig,
private readonly poolFactory: PostgresPoolFactory = (poolConfig) =>
new Pool(poolConfig) as unknown as PostgresPool,
) {
super(config);
}
override async onServerStart(): Promise<void> {
const dbConf = this.config.database!;
this.primaryPool = this.createPool(dbConf);
await this.primaryPool.query('SELECT 1');
console.log('[postgres] connected to primary');
if (dbConf.replica) {
this.replicaPool = this.createPool(dbConf.replica);
await this.replicaPool.query('SELECT 1');
this.configuration = Configuration.REPLICA;
console.log('[postgres] connected to read-replica');
} else {
this.replicaPool = this.primaryPool;
this.configuration = Configuration.SINGLE;
}
await this.runMigrations();
}
override async onServerPrepareShutdown(): Promise<void> {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
const drainMs = 60_000;
console.log(
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools().catch((e) =>
console.error('[postgres] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools();
}
override quoteIdentifier(identifier: string): string {
return identifier
.split('.')
.map((part) => {
if (part === '*') return part;
return `"${part.replaceAll('"', '""')}"`;
})
.join('.');
}
override booleanLiteral(value: boolean): string {
return value ? 'TRUE' : 'FALSE';
}
override booleanValue(value: boolean): boolean {
return value;
}
override async read(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.query(this.replicaPool, query, params);
return normalizePostgresRows(result);
}
override async pread(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.query(this.primaryPool, query, params);
return normalizePostgresRows(result);
}
override async write(
query: string,
params: unknown[] = [],
): Promise<WriteResult> {
const result = await this.query(this.primaryPool, query, params);
return mapPostgresWriteResult(result);
}
override async batchWrite(entries: BatchEntry[]): Promise<void> {
if (entries.length === 0) return;
const conn = await this.primaryPool.connect();
try {
await conn.query('BEGIN');
try {
for (const { statement, values } of entries) {
await this.query(conn, statement, values);
}
await conn.query('COMMIT');
} catch (err) {
await conn.query('ROLLBACK').catch(() => {});
throw err;
}
} finally {
conn.release();
}
}
override async tryHardRead(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
if (this.configuration === Configuration.SINGLE) {
return this.read(query, params);
}
const primaryPromise = this.query(this.primaryPool, query, params);
try {
const replicaResult = await this.query(
this.replicaPool,
query,
params,
);
if (replicaResult.rows.length > 0) {
primaryPromise.catch(() => {});
return normalizePostgresRows(replicaResult);
}
} catch {
// fall through to primary
}
const primaryResult = await primaryPromise;
return normalizePostgresRows(primaryResult);
}
private async runMigrations(): Promise<void> {
const paths = this.config.database?.migrationPaths;
if (!paths || paths.length === 0) return;
const conn = await this.primaryPool.connect();
try {
for (const rawPath of paths) {
const dir = isAbsolute(rawPath)
? rawPath
: resolvePath(process.cwd(), rawPath);
let files: string[];
try {
files = readdirSync(dir)
.filter(
(f) =>
f.endsWith('.sql') && f.startsWith('postgres'),
)
.sort(compareMigrationFilenames);
} catch (e) {
throw new Error(
`[postgres] migration path is unreadable: ${dir}`,
{ cause: e },
);
}
if (files.length === 0) {
console.log(`[postgres] no migrations in ${dir}`);
continue;
}
console.log(
`[postgres] running migrations from ${dir}: ${files.length} file(s)`,
);
for (const file of files) {
const filePath = resolvePath(dir, file);
const contents = readFileSync(filePath, 'utf8');
const statements = splitPostgresStatements(contents);
await conn.query('BEGIN');
try {
for (let i = 0; i < statements.length; i++) {
try {
await conn.query(statements[i]);
} catch (e) {
throw new Error(
`[postgres] failed to apply ${file} at statement ${i}`,
{ cause: e },
);
}
}
await conn.query('COMMIT');
} catch (e) {
await conn.query('ROLLBACK').catch(() => {});
throw e;
}
console.log(
`[postgres] applied ${file} (${statements.length} statements)`,
);
}
}
} finally {
conn.release();
}
}
private createPool(dbConf: PostgresEndpointConfig): PostgresPool {
const connectionString = dbConf.connectionString ?? dbConf.url;
if (connectionString) {
return this.poolFactory({
connectionString,
max: 30,
});
}
return this.poolFactory({
host: dbConf.host ?? '127.0.0.1',
port: dbConf.port ?? 5432,
user: dbConf.user ?? 'postgres',
password: dbConf.password ?? '',
database: dbConf.database ?? 'puter',
max: 30,
});
}
private async query(
target: PostgresQueryable,
query: string,
params: unknown[] = [],
): Promise<QueryResult> {
const prepared = preparePostgresSql(query);
return target.query(prepared.text, params);
}
private async closeCurrentPools(): Promise<void> {
const tasks: Promise<void>[] = [];
if (this.primaryPool) tasks.push(this.primaryPool.end());
if (this.replicaPool && this.replicaPool !== this.primaryPool) {
tasks.push(this.replicaPool.end());
}
await Promise.all(tasks);
}
}
@@ -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 {
+4
View File
@@ -24,10 +24,12 @@ export {
} from './DatabaseClient';
export { SqliteDatabaseClient } from './SqliteDatabaseClient';
export { MySQLDatabaseClient } from './MySQLDatabaseClient';
export { PostgresDatabaseClient } from './PostgresDatabaseClient';
import type { IConfig } from '../../types';
import { AbstractDatabaseClient } from './DatabaseClient';
import { MySQLDatabaseClient } from './MySQLDatabaseClient';
import { PostgresDatabaseClient } from './PostgresDatabaseClient';
import { SqliteDatabaseClient } from './SqliteDatabaseClient';
/**
@@ -41,6 +43,8 @@ export const DatabaseClientFactory = class DatabaseClientFactory {
switch (engine) {
case 'mysql':
return new MySQLDatabaseClient(config);
case 'postgres':
return new PostgresDatabaseClient(config);
case 'sqlite':
return new SqliteDatabaseClient(config);
default:
@@ -0,0 +1,43 @@
/**
* 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/>.
*/
/**
* Comparator for migration filenames named `<engine>_mig_<N>.sql`.
*
* Existing MySQL files use unpadded integers, so lexical sorting places
* `*_mig_10.sql` before `*_mig_2.sql`. Pull the trailing integer out and sort
* numerically. Anything that does not match the `_<digits>.sql` shape falls
* back to lexical comparison and sorts after numbered files.
*/
export const compareMigrationFilenames = (a: string, b: string): number => {
const numericIndex = (name: string): number => {
const m = /_(\d+)\.sql$/.exec(name);
return m ? Number.parseInt(m[1], 10) : Number.NaN;
};
const na = numericIndex(a);
const nb = numericIndex(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
if (na !== nb) return na - nb;
} else if (Number.isFinite(na)) {
return -1;
} else if (Number.isFinite(nb)) {
return 1;
}
return a.localeCompare(b);
};
@@ -0,0 +1,724 @@
-- 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/>.
CREATE TABLE IF NOT EXISTS "user" (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(36) NOT NULL UNIQUE,
username varchar(50) UNIQUE,
email varchar(256),
password varchar(225),
free_storage bigint,
max_subdomains integer,
taskbar_items text,
desktop_uuid varchar(36),
appdata_uuid varchar(36),
documents_uuid varchar(36),
pictures_uuid varchar(36),
videos_uuid varchar(36),
trash_uuid varchar(36),
trash_id integer,
appdata_id integer,
desktop_id integer,
documents_id integer,
pictures_id integer,
videos_id integer,
referrer varchar(64),
desktop_bg_url text,
desktop_bg_color varchar(20),
desktop_bg_fit varchar(16),
pass_recovery_token varchar(36),
requires_email_confirmation boolean NOT NULL DEFAULT FALSE,
email_confirm_code varchar(8),
email_confirm_token varchar(36),
email_confirmed boolean NOT NULL DEFAULT FALSE,
dev_first_name varchar(100),
dev_last_name varchar(100),
dev_paypal varchar(100),
dev_approved_for_incentive_program boolean DEFAULT FALSE,
dev_joined_incentive_program boolean DEFAULT FALSE,
suspended boolean,
unsubscribed boolean NOT NULL DEFAULT FALSE,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_activity_ts timestamp,
referral_code varchar(16) UNIQUE,
referred_by integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
unconfirmed_change_email varchar(256),
change_email_confirm_token varchar(256),
otp_secret text,
otp_enabled boolean DEFAULT FALSE,
otp_recovery_codes text,
stripe_customer_id varchar(40),
public_uuid varchar(36),
public_id integer,
clean_email varchar(256),
audit_metadata jsonb,
signup_ip varchar(45),
signup_ip_forwarded varchar(45),
signup_user_agent varchar(512),
signup_origin varchar(255),
signup_server varchar(255),
metadata jsonb DEFAULT '{}'::jsonb,
reputation smallint DEFAULT 100
);
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
CREATE INDEX IF NOT EXISTS idx_user_pass_recovery_token ON "user" (pass_recovery_token);
CREATE INDEX IF NOT EXISTS idx_user_referrer ON "user" (referrer);
CREATE INDEX IF NOT EXISTS idx_user_email_confirm_token ON "user" (email_confirm_token);
CREATE INDEX IF NOT EXISTS idx_user_last_activity_ts ON "user" (last_activity_ts);
CREATE INDEX IF NOT EXISTS idx_user_referred_by ON "user" (referred_by);
CREATE INDEX IF NOT EXISTS idx_user_stripe_customer_id ON "user" (stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_user_clean_email ON "user" (clean_email);
CREATE INDEX IF NOT EXISTS idx_user_signup_ip ON "user" (signup_ip);
CREATE INDEX IF NOT EXISTS idx_user_signup_ip_forwarded ON "user" (signup_ip_forwarded);
CREATE INDEX IF NOT EXISTS idx_user_signup_user_agent ON "user" (signup_user_agent);
CREATE INDEX IF NOT EXISTS idx_user_signup_origin ON "user" (signup_origin);
CREATE INDEX IF NOT EXISTS idx_user_signup_server ON "user" (signup_server);
CREATE TABLE IF NOT EXISTS apps (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
owner_user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
icon text,
name varchar(100) NOT NULL UNIQUE,
title varchar(100) NOT NULL,
description text,
godmode boolean DEFAULT FALSE,
maximize_on_start boolean DEFAULT FALSE,
index_url text NOT NULL,
approved_for_listing boolean DEFAULT FALSE,
approved_for_opening_items boolean DEFAULT FALSE,
approved_for_incentive_program boolean DEFAULT FALSE,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_review timestamp,
tags varchar(255),
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
background boolean DEFAULT FALSE,
metadata jsonb,
protected boolean DEFAULT FALSE,
is_private boolean DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_apps_owner_user_id ON apps (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_apps_app_owner ON apps (app_owner);
CREATE INDEX IF NOT EXISTS idx_apps_owner_timestamp ON apps (owner_user_id, "timestamp" DESC);
CREATE INDEX IF NOT EXISTS idx_apps_listing_timestamp ON apps (approved_for_listing, "timestamp" DESC);
CREATE TABLE IF NOT EXISTS "group" (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) UNIQUE,
owner_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
extra jsonb,
metadata jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_group_owner_user_id ON "group" (owner_user_id);
CREATE TABLE IF NOT EXISTS app_filetype_association (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
type varchar(60) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_app_id ON app_filetype_association (app_id);
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_type ON app_filetype_association (type);
CREATE TABLE IF NOT EXISTS app_opens (
_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE ON UPDATE CASCADE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
ts integer NOT NULL,
human_ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_opens_user_id ON app_opens (user_id);
CREATE INDEX IF NOT EXISTS idx_app_opens_app_uid ON app_opens (app_uid);
CREATE INDEX IF NOT EXISTS idx_app_opens_uid_ts ON app_opens (app_uid, ts);
CREATE INDEX IF NOT EXISTS idx_app_opens_app_user ON app_opens (app_uid, user_id);
CREATE TABLE IF NOT EXISTS fsentries (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(36) NOT NULL UNIQUE,
bucket varchar(50),
bucket_region varchar(30),
public_token varchar(36) UNIQUE,
file_request_token varchar(36) UNIQUE,
is_shortcut boolean DEFAULT FALSE,
shortcut_to integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
parent_id integer REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
associated_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
is_dir boolean DEFAULT FALSE,
layout varchar(30),
sort_by varchar(20),
sort_order varchar(10),
is_public boolean,
thumbnail text,
immutable boolean NOT NULL DEFAULT FALSE,
name varchar(767) NOT NULL,
metadata text,
modified integer NOT NULL,
created integer,
accessed integer,
size bigint,
symlink_path varchar(260),
is_symlink boolean DEFAULT FALSE,
parent_uid varchar(36),
path varchar(4096),
CONSTRAINT fsentries_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX IF NOT EXISTS idx_fsentries_name ON fsentries (name);
CREATE INDEX IF NOT EXISTS idx_fsentries_modified ON fsentries (modified);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_id ON fsentries (parent_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_is_dir ON fsentries (is_dir);
CREATE INDEX IF NOT EXISTS idx_fsentries_user_id ON fsentries (user_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_shortcut_to ON fsentries (shortcut_to);
CREATE INDEX IF NOT EXISTS idx_fsentries_associated_app_id ON fsentries (associated_app_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket ON fsentries (bucket);
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket_region ON fsentries (bucket_region);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid ON fsentries (parent_uid);
CREATE INDEX IF NOT EXISTS idx_fsentries_path ON fsentries (path);
CREATE INDEX IF NOT EXISTS idx_fsentries_accessed ON fsentries (accessed);
CREATE INDEX IF NOT EXISTS idx_fsentries_user_parent_name ON fsentries (user_id, parent_uid, name);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid_name ON fsentries (parent_uid, name);
CREATE TABLE IF NOT EXISTS fsentry_versions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_uuid varchar(36) NOT NULL,
version_id varchar(60) NOT NULL,
user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
message text,
ts_epoch integer
);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_id ON fsentry_versions (fsentry_id);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_uuid ON fsentry_versions (fsentry_uuid);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_user_id ON fsentry_versions (user_id);
CREATE TABLE IF NOT EXISTS subdomains (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(40) UNIQUE,
subdomain varchar(64) NOT NULL UNIQUE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
root_dir_id integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
associated_app_id integer REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
ts timestamp DEFAULT CURRENT_TIMESTAMP,
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
protected boolean DEFAULT FALSE,
domain varchar(265),
database_id varchar(40),
preamble_version varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_subdomains_user_id ON subdomains (user_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_root_dir_id ON subdomains (root_dir_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_associated_app_id ON subdomains (associated_app_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_app_owner ON subdomains (app_owner);
CREATE INDEX IF NOT EXISTS idx_subdomains_domain ON subdomains (domain);
CREATE INDEX IF NOT EXISTS idx_subdomains_root_user ON subdomains (root_dir_id, user_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_app_user ON subdomains (associated_app_id, user_id);
CREATE TABLE IF NOT EXISTS kv (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app varchar(40),
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
kkey_hash bigint NOT NULL,
kkey text NOT NULL,
value text,
migrated boolean DEFAULT FALSE,
"expireAt" timestamp,
CONSTRAINT kv_app_user_kkey_hash_unique UNIQUE (app, user_id, kkey_hash)
);
CREATE INDEX IF NOT EXISTS idx_kv_app ON kv (app);
CREATE INDEX IF NOT EXISTS idx_kv_user_id ON kv (user_id);
CREATE INDEX IF NOT EXISTS idx_kv_kkey_hash ON kv (kkey_hash);
CREATE TABLE IF NOT EXISTS sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
uuid varchar(40) NOT NULL,
meta jsonb,
created_at bigint DEFAULT 0,
last_activity bigint DEFAULT 0,
kind varchar(32) NOT NULL DEFAULT 'web'
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker')),
label varchar(255),
parent_session_id varchar(64),
last_ip varchar(64),
last_user_agent varchar(512),
revoked_at bigint,
expires_at bigint,
app_uid varchar(64),
legacy_token_uid varchar(64),
created_via varchar(32),
auth_id varchar(64),
access_token_uid varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions (uuid);
CREATE INDEX IF NOT EXISTS idx_sessions_user_revoked ON sessions (user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_app_active
ON sessions (user_id, app_uid)
WHERE kind = 'app' AND revoked_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_legacy_token_active
ON sessions (legacy_token_uid)
WHERE legacy_token_uid IS NOT NULL AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_kind_user ON sessions (kind, user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_access_token_uid
ON sessions (access_token_uid)
WHERE access_token_uid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_worker_active
ON sessions (user_id, COALESCE(app_uid, ''), (meta #>> ARRAY['worker_name']))
WHERE kind = 'worker' AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS user_to_app_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
dt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, app_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_utap_user_permission ON user_to_app_permissions (user_id, permission);
CREATE INDEX IF NOT EXISTS idx_utap_app_permission ON user_to_app_permissions (app_id, permission);
CREATE TABLE IF NOT EXISTS user_to_group_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (user_id, group_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_user_to_group_permissions_group_id ON user_to_group_permissions (group_id);
CREATE TABLE IF NOT EXISTS user_to_user_permissions (
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
holder_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (issuer_user_id, holder_user_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_user_id ON user_to_user_permissions (holder_user_id);
CREATE TABLE IF NOT EXISTS dev_to_app_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (user_id, app_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_app_id ON dev_to_app_permissions (app_id);
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission ON dev_to_app_permissions (permission);
CREATE TABLE IF NOT EXISTS access_token_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
token_uid varchar(40) NOT NULL,
authorizer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
authorizer_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_access_token_permissions_token_uid ON access_token_permissions (token_uid);
CREATE TABLE IF NOT EXISTS old_app_names (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE,
name varchar(255) NOT NULL,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_old_app_names_app_uid_name UNIQUE (app_uid, name)
);
CREATE INDEX IF NOT EXISTS idx_old_app_names_app_uid ON old_app_names (app_uid);
CREATE INDEX IF NOT EXISTS idx_old_app_names_name ON old_app_names (name);
CREATE TABLE IF NOT EXISTS jct_user_group (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
extra jsonb,
metadata jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_jct_user_group_user_id ON jct_user_group (user_id);
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group_id ON jct_user_group (group_id);
CREATE TABLE IF NOT EXISTS notification (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
uid varchar(40) UNIQUE,
value jsonb NOT NULL,
acknowledged bigint,
shown bigint,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
CREATE TABLE IF NOT EXISTS share (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) UNIQUE,
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
recipient_email varchar(255) NOT NULL,
data jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_share_issuer_user_id ON share (issuer_user_id);
CREATE INDEX IF NOT EXISTS idx_share_recipient_email ON share (recipient_email);
CREATE TABLE IF NOT EXISTS feedback (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
message text,
ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback (user_id);
CREATE TABLE IF NOT EXISTS thread (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
parent_uid varchar(40) REFERENCES thread (uid) ON DELETE CASCADE ON UPDATE CASCADE,
owner_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
"schema" text,
text text NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_thread_parent_uid ON thread (parent_uid);
CREATE INDEX IF NOT EXISTS idx_thread_owner_user_id ON thread (owner_user_id);
CREATE TABLE IF NOT EXISTS user_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
metadata jsonb,
text text NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_comments_user_id ON user_comments (user_id);
CREATE INDEX IF NOT EXISTS idx_user_comments_uid ON user_comments (uid);
CREATE TABLE IF NOT EXISTS user_fsentry_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_user_comment_id ON user_fsentry_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_fsentry_id ON user_fsentry_comments (fsentry_id);
CREATE TABLE IF NOT EXISTS user_fsentry_version_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_version_id integer NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_user_comment_id ON user_fsentry_version_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_version_id ON user_fsentry_version_comments (fsentry_version_id);
CREATE TABLE IF NOT EXISTS user_group_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_group_comments_user_comment_id ON user_group_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_group_comments_group_id ON user_group_comments (group_id);
CREATE TABLE IF NOT EXISTS user_user_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
commented_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_user_comments_user_comment_id ON user_user_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_user_comments_commented_user_id ON user_user_comments (commented_user_id);
CREATE TABLE IF NOT EXISTS user_oidc_providers (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
provider varchar(64) NOT NULL,
provider_sub varchar(255) NOT NULL,
refresh_token text,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_user_id ON user_oidc_providers (user_id);
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_provider ON user_oidc_providers (provider);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_providers_provider_sub_unique ON user_oidc_providers (provider, provider_sub);
CREATE TABLE IF NOT EXISTS app_update_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
old_name varchar(50),
new_name varchar(50),
reason varchar(255)
);
CREATE INDEX IF NOT EXISTS idx_app_update_audit_app_id ON app_update_audit (app_id);
CREATE TABLE IF NOT EXISTS user_update_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
old_email varchar(256),
new_email varchar(256),
old_username varchar(50),
new_username varchar(50),
reason varchar(255)
);
CREATE INDEX IF NOT EXISTS idx_user_update_audit_user_id ON user_update_audit (user_id);
CREATE TABLE IF NOT EXISTS storage_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
is_subtract boolean NOT NULL DEFAULT FALSE,
amount bigint NOT NULL,
field_a varchar(16),
field_b varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_storage_audit_user_id ON storage_audit (user_id);
CREATE TABLE IF NOT EXISTS audit_user_to_app_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_user_id ON audit_user_to_app_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_app_id ON audit_user_to_app_permissions (app_id);
CREATE TABLE IF NOT EXISTS audit_dev_to_app_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_user_id ON audit_dev_to_app_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_app_id ON audit_dev_to_app_permissions (app_id);
CREATE TABLE IF NOT EXISTS audit_user_to_group_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
group_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(255),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_user_id ON audit_user_to_group_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_group_id ON audit_user_to_group_permissions (group_id);
CREATE TABLE IF NOT EXISTS audit_user_to_user_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
issuer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
issuer_user_id_keep integer NOT NULL,
holder_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
holder_user_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_issuer_user_id ON audit_user_to_user_permissions (issuer_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_holder_user_id ON audit_user_to_user_permissions (holder_user_id);
CREATE TABLE IF NOT EXISTS ai_usage (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
service_name varchar(64),
model_name varchar(128),
price_modifier varchar(40),
cost integer,
value_uint_1 integer,
value_uint_2 integer,
value_uint_3 integer,
value_uint_4 integer,
value_uint_5 integer,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ai_usage_app_id ON ai_usage (app_id);
CREATE INDEX IF NOT EXISTS idx_ai_usage_service_name ON ai_usage (service_name);
CREATE INDEX IF NOT EXISTS idx_ai_usage_model_name ON ai_usage (model_name);
CREATE INDEX IF NOT EXISTS idx_ai_usage_price_modifier ON ai_usage (price_modifier);
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage (created_at);
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_timestamp ON ai_usage (user_id, created_at);
CREATE TABLE IF NOT EXISTS general_analytics (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
uid varchar(40) NOT NULL,
trace_id varchar(40),
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer,
server_id varchar(40),
actor_type varchar(40),
tags jsonb,
fields jsonb
);
CREATE INDEX IF NOT EXISTS idx_general_analytics_user_id ON general_analytics (user_id);
CREATE INDEX IF NOT EXISTS idx_general_analytics_app_id ON general_analytics (app_id);
CREATE TABLE IF NOT EXISTS monthly_usage_counts (
year integer NOT NULL,
month integer NOT NULL,
service_type varchar(40) NOT NULL,
service_name varchar(40) NOT NULL,
actor_key varchar(255) NOT NULL,
pricing_category jsonb NOT NULL,
pricing_category_hash bytea NOT NULL,
"count" integer DEFAULT 0,
value_uint_1 integer,
value_uint_2 integer,
value_uint_3 integer,
PRIMARY KEY (year, month, service_type, service_name, actor_key, pricing_category_hash)
);
CREATE TABLE IF NOT EXISTS service_usage_monthly (
"key" varchar(255) NOT NULL,
year integer NOT NULL,
month integer NOT NULL,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
"count" integer NOT NULL,
extra jsonb,
PRIMARY KEY ("key", year, month)
);
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_user_id ON service_usage_monthly (user_id);
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_app_id ON service_usage_monthly (app_id);
CREATE TABLE IF NOT EXISTS per_user_credit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL UNIQUE REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
amount bigint NOT NULL,
last_updated_at bigint NOT NULL
);
INSERT INTO "user" (id, uuid, username, metadata)
VALUES (1, '5d4adce0-a381-4982-9c02-6e2540026238', 'system', '{}'::jsonb)
ON CONFLICT (uuid) DO UPDATE SET username = EXCLUDED.username;
SELECT setval(pg_get_serial_sequence('"user"', 'id'), COALESCE((SELECT MAX(id) FROM "user"), 1), true);
INSERT INTO "group" (uid, owner_user_id, extra, metadata)
VALUES
('26bfb1fb-421f-45bc-9aa4-d81ea569e7a5', 1, '{"critical": true, "type": "default", "name": "system"}'::jsonb, '{"title": "System", "color": "#000000"}'::jsonb),
('ca342a5e-b13d-4dee-9048-58b11a57cc55', 1, '{"critical": true, "type": "default", "name": "admin"}'::jsonb, '{"title": "Admin", "color": "#a83232"}'::jsonb),
('78b1b1dd-c959-44d2-b02c-8735671f9997', 1, '{"critical": true, "type": "default", "name": "user"}'::jsonb, '{"title": "User", "color": "#3254a8"}'::jsonb),
('b7220104-7905-4985-b996-649fdcdb3c8f', 1, '{"critical": true, "type": "default", "name": "temp"}'::jsonb, '{"title": "Temp", "color": "#888888"}'::jsonb),
('3c2dfff7-d22a-41aa-a193-59a61dac4b64', 1, '{"type": "default", "name": "moderator"}'::jsonb, '{"title": "Moderator", "color": "#a432a8"}'::jsonb),
('5e8f251d-3382-4b0d-932c-7bb82f48652f', 1, '{"type": "default", "name": "developer"}'::jsonb, '{"title": "Developer", "color": "#32a852"}'::jsonb)
ON CONFLICT (uid) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
extra = EXCLUDED.extra,
metadata = EXCLUDED.metadata;
INSERT INTO apps (
uid, owner_user_id, icon, name, title, description, index_url,
godmode, maximize_on_start, background,
approved_for_listing, approved_for_opening_items,
approved_for_incentive_program, tags, "timestamp"
)
VALUES
('app-3920851d-bda8-479b-9407-8517293c7d44', 1, NULL, 'pdf', 'PDF', '', 'https://pdf.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', 1, NULL, 'camera', 'Camera', 'Camera in the browser.', 'https://online-camera.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-11edfba2-1ed3-4e22-8573-47e88fb87d70', 1, NULL, 'player', 'Player', 'A free video player app in the browser.', 'https://simple-player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', 1, NULL, 'recorder', 'Recorder', 'Online voice recorder in the browser with cloud storage.', 'https://voice-recorder.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-d7e9471f-e441-4d72-a5ab-75e96573b76b', 1, NULL, 'music-player', 'Music Player', 'A free music player app in the browser.', 'https://player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2026-05-10 00:00:00'),
('app-e3ac5486-da8c-42ad-8377-8728086e0980', 1, NULL, 'git', 'Git', 'Puter Git client', 'https://builtins.namespaces.puter.com/git', FALSE, FALSE, TRUE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-0b37f054-07d4-4627-8765-11bd23e889d4', 1, NULL, 'dev-center', 'Dev Center', 'This is the app that makes apps', 'https://builtins.namespaces.puter.com/dev-center', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-fbbdb72b-ad08-4cb4-86a1-de0f27cf2e1e', 1, NULL, 'puter-linux', 'Puter Linux', 'Linux emulator for Puter', 'https://builtins.namespaces.puter.com/emulator', TRUE, FALSE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', 1, NULL, 'editor', 'Editor', 'Text editor', 'https://online-notepad.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-58282b08-990a-4906-95f7-fa37ff92452b', 1, NULL, 'draw', 'Draw', 'Image editor', 'https://draw.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'graphics', '2020-01-01 00:00:00'),
('app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', 1, NULL, 'about', 'About', 'About Puter', 'https://about.puter.com', TRUE, FALSE, FALSE, FALSE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', 1, NULL, 'app-center', 'App Center', 'Discover apps for Puter', 'https://app-center.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-93005ce0-80d1-50d9-9b1e-9c453c375d56', 1, NULL, 'markus', 'Markus', 'Markdown editor', 'https://markus.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-6f79ef7b-52b7-4b31-91c6-fc07b62e9396', 1, NULL, 'code', 'Code', 'Code editor', 'https://code.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-862fc09e-c7b8-4c30-b5b4-47b3cc5f5232', 1, NULL, 'memos', 'Memos', 'Notes and memos', 'https://memos.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-aeb8c03e-1144-4c57-bff5-3a1a7c17f9f0', 1, NULL, 'word-processor', 'Word Processor', 'Write documents', 'https://word-processor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-c10a1999-22f5-4644-9960-9aaac0d4934e', 1, NULL, 'spreadsheet', 'Spreadsheet', 'Work with spreadsheets', 'https://spreadsheet.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-e9c1d58d-3d8d-4f0a-a85c-e383ef63bc29', 1, NULL, 'presentation', 'Presentation', 'Create presentations', 'https://presentation.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-fb7d9e42-8207-4fa0-b680-f0239327097f', 1, NULL, 'pdf-editor', 'PDF Editor', 'Edit PDF documents', 'https://pdf-editor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00')
ON CONFLICT (uid) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
name = EXCLUDED.name,
title = EXCLUDED.title,
description = EXCLUDED.description,
index_url = EXCLUDED.index_url,
godmode = EXCLUDED.godmode,
maximize_on_start = EXCLUDED.maximize_on_start,
background = EXCLUDED.background,
approved_for_listing = EXCLUDED.approved_for_listing,
approved_for_opening_items = EXCLUDED.approved_for_opening_items,
approved_for_incentive_program = EXCLUDED.approved_for_incentive_program,
tags = EXCLUDED.tags;
INSERT INTO user_to_group_permissions (user_id, group_id, permission, extra)
SELECT u.id, g.id, 'driver', '{}'::jsonb
FROM "user" u, "group" g
WHERE u.username = 'system'
AND g.uid = 'ca342a5e-b13d-4dee-9048-58b11a57cc55'
ON CONFLICT (user_id, group_id, permission) DO UPDATE SET extra = EXCLUDED.extra;
@@ -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;
@@ -0,0 +1,85 @@
/**
* 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 { preparePostgresSql } from './preparePostgresSql.js';
describe('preparePostgresSql', () => {
it('converts placeholders and backtick identifiers outside SQL literals', () => {
const prepared = preparePostgresSql(
'SELECT `user`.`id` FROM `user` WHERE `email` = ? AND `username` = ?',
);
expect(prepared).toEqual({
text: 'SELECT "user"."id" FROM "user" WHERE "email" = $1 AND "username" = $2',
parameterCount: 2,
});
});
it('leaves question marks inside strings and comments untouched', () => {
const prepared = preparePostgresSql(`
SELECT '?' AS literal, ?
-- ? in a comment
FROM \`apps\`
WHERE \`description\` = 'is this ok?'
/* and ? in a block comment */
AND \`name\` = ?
`);
expect(prepared.text).toContain("SELECT '?' AS literal, $1");
expect(prepared.text).toContain('-- ? in a comment');
expect(prepared.text).toContain('"description" = \'is this ok?\'');
expect(prepared.text).toContain('/* and ? in a block comment */');
expect(prepared.text).toContain('"name" = $2');
expect(prepared.parameterCount).toBe(2);
});
it('escapes double quotes inside converted identifiers', () => {
const prepared = preparePostgresSql(
'SELECT `odd"name` FROM `odd``table` WHERE `id` = ?',
);
expect(prepared).toEqual({
text: 'SELECT "odd""name" FROM "odd`table" WHERE "id" = $1',
parameterCount: 1,
});
});
it('does not scan placeholders inside dollar-quoted strings', () => {
const prepared = preparePostgresSql(
"SELECT $$?$$, $tag$`not_ident` ?$tag$, `id` FROM `user` WHERE `id` = ?",
);
expect(prepared).toEqual({
text: 'SELECT $$?$$, $tag$`not_ident` ?$tag$, "id" FROM "user" WHERE "id" = $1',
parameterCount: 1,
});
});
it('does not treat Postgres JSON operators as comments', () => {
const prepared = preparePostgresSql(
"SELECT * FROM `sessions` WHERE `user_id` = ? AND `meta` #>> ARRAY['worker_name'] = ? AND `expires_at` > ?",
);
expect(prepared).toEqual({
text: 'SELECT * FROM "sessions" WHERE "user_id" = $1 AND "meta" #>> ARRAY[\'worker_name\'] = $2 AND "expires_at" > $3',
parameterCount: 3,
});
});
});
@@ -0,0 +1,169 @@
/**
* 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/>.
*/
export interface PreparedPostgresSql {
text: string;
parameterCount: number;
}
type ScannerState =
| { kind: 'normal' }
| { kind: 'singleQuote' }
| { kind: 'doubleQuote' }
| { kind: 'backtickIdentifier' }
| { kind: 'lineComment' }
| { kind: 'blockComment' }
| { kind: 'dollarQuote'; tag: string };
const isLineCommentStart = (sql: string, index: number): boolean => {
if (sql[index] !== '-' || sql[index + 1] !== '-') return false;
const after = sql[index + 2];
return after === undefined || /\s/u.test(after);
};
const readDollarQuoteTag = (sql: string, index: number): string | null => {
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
return match?.[0] ?? null;
};
export const preparePostgresSql = (sql: string): PreparedPostgresSql => {
let state: ScannerState = { kind: 'normal' };
let parameterCount = 0;
let out = '';
for (let i = 0; i < sql.length; i++) {
const char = sql[i]!;
const next = sql[i + 1];
switch (state.kind) {
case 'normal': {
const dollarTag =
char === '$' ? readDollarQuoteTag(sql, i) : null;
if (dollarTag) {
out += dollarTag;
i += dollarTag.length - 1;
state = { kind: 'dollarQuote', tag: dollarTag };
break;
}
if (char === "'") {
out += char;
state = { kind: 'singleQuote' };
break;
}
if (char === '"') {
out += char;
state = { kind: 'doubleQuote' };
break;
}
if (char === '`') {
out += '"';
state = { kind: 'backtickIdentifier' };
break;
}
if (isLineCommentStart(sql, i)) {
out += char;
if (char === '-') {
out += next ?? '';
i += 1;
}
state = { kind: 'lineComment' };
break;
}
if (char === '/' && next === '*') {
out += '/*';
i += 1;
state = { kind: 'blockComment' };
break;
}
if (char === '?') {
parameterCount += 1;
out += `$${parameterCount}`;
break;
}
out += char;
break;
}
case 'singleQuote':
out += char;
if (char === "'" && next === "'") {
out += next;
i += 1;
} else if (char === '\\' && next !== undefined) {
out += next;
i += 1;
} else if (char === "'") {
state = { kind: 'normal' };
}
break;
case 'doubleQuote':
out += char;
if (char === '"' && next === '"') {
out += next;
i += 1;
} else if (char === '"') {
state = { kind: 'normal' };
}
break;
case 'backtickIdentifier':
if (char === '`' && next === '`') {
out += '`';
i += 1;
} else if (char === '`') {
out += '"';
state = { kind: 'normal' };
} else if (char === '"') {
out += '""';
} else {
out += char;
}
break;
case 'lineComment':
out += char;
if (char === '\n') {
state = { kind: 'normal' };
}
break;
case 'blockComment':
out += char;
if (char === '*' && next === '/') {
out += '/';
i += 1;
state = { kind: 'normal' };
}
break;
case 'dollarQuote':
if (sql.startsWith(state.tag, i)) {
out += state.tag;
i += state.tag.length - 1;
state = { kind: 'normal' };
} else {
out += char;
}
break;
}
}
return { text: out, parameterCount };
};
@@ -0,0 +1,120 @@
/**
* 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/>.
*/
type ScannerState =
| { kind: 'normal' }
| { kind: 'singleQuote' }
| { kind: 'doubleQuote' }
| { kind: 'lineComment' }
| { kind: 'blockComment' }
| { kind: 'dollarQuote'; tag: string };
const readDollarQuoteTag = (sql: string, index: number): string | null => {
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
return match?.[0] ?? null;
};
export const splitPostgresStatements = (sql: string): string[] => {
const statements: string[] = [];
let state: ScannerState = { kind: 'normal' };
let start = 0;
for (let i = 0; i < sql.length; i++) {
const char = sql[i]!;
const next = sql[i + 1];
switch (state.kind) {
case 'normal': {
const dollarTag =
char === '$' ? readDollarQuoteTag(sql, i) : null;
if (dollarTag) {
i += dollarTag.length - 1;
state = { kind: 'dollarQuote', tag: dollarTag };
break;
}
if (char === "'") {
state = { kind: 'singleQuote' };
break;
}
if (char === '"') {
state = { kind: 'doubleQuote' };
break;
}
if (char === '-' && next === '-') {
i += 1;
state = { kind: 'lineComment' };
break;
}
if (char === '/' && next === '*') {
i += 1;
state = { kind: 'blockComment' };
break;
}
if (char === ';') {
const statement = sql.slice(start, i).trim();
if (statement !== '') statements.push(statement);
start = i + 1;
}
break;
}
case 'singleQuote':
if (char === "'" && next === "'") {
i += 1;
} else if (char === '\\' && next !== undefined) {
i += 1;
} else if (char === "'") {
state = { kind: 'normal' };
}
break;
case 'doubleQuote':
if (char === '"' && next === '"') {
i += 1;
} else if (char === '"') {
state = { kind: 'normal' };
}
break;
case 'lineComment':
if (char === '\n') {
state = { kind: 'normal' };
}
break;
case 'blockComment':
if (char === '*' && next === '/') {
i += 1;
state = { kind: 'normal' };
}
break;
case 'dollarQuote':
if (sql.startsWith(state.tag, i)) {
i += state.tag.length - 1;
state = { kind: 'normal' };
}
break;
}
}
const finalStatement = sql.slice(start).trim();
if (finalStatement !== '') statements.push(finalStatement);
return statements;
};
+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;
+9
View File
@@ -18,6 +18,7 @@
*/
import type { IConfig, WithLifecycle } from '../types';
import type { ClickhouseClient } from './clickhouse/ClickhouseClient';
/**
* Extension-augmentable client registry. Extensions add their own client
@@ -40,6 +41,14 @@ export interface IExtensionClientInstances {
* override this for that name.
*/
[key: string]: unknown;
/**
* Optional ClickHouse analytics client. Absent by default — a production
* deployment registers it via an extension to speed up the app-stats path
* at scale (see {@link ClickhouseClient}). Always branch on its presence
* and fall back to SQL.
*/
clickhouse?: ClickhouseClient;
}
export interface IPuterClient<T extends WithLifecycle = WithLifecycle> {
+36 -1
View File
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { driversContainers } from '../../exports.js';
import {
@@ -55,6 +56,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const apps = await this.appDriver.select({
@@ -70,6 +72,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const name = req.query?.name;
@@ -89,6 +92,16 @@ export class AppController extends PuterController {
// actor calls this, the app id is already on the token — clients
// don't re-send it in the body. Fall back to `actor.app.uid`
// before 400-ing for a missing body field.
//
// Authorization: only two callers are trusted to report opens —
// 1. a root user actor (plain session, no `.app` and no access
// token), e.g. the GUI launching apps on behalf of the user;
// 2. the app-under-user actor for the app being reported, i.e.
// `actor.app.uid === app_uid`.
// Everything else — access tokens (regardless of issuer), asset
// tokens, app actors reporting for a *different* app — is denied,
// otherwise any authenticated party could inflate another app's
// open count.
router.post(
'/rao',
{
@@ -96,8 +109,9 @@ export class AppController extends PuterController {
requireAuth: true,
},
async (req, res) => {
const actor = req.actor;
const bodyAppUid = req.body?.app_uid;
const actorAppUid = req.actor?.app?.uid;
const actorAppUid = actor?.app?.uid;
const app_uid =
typeof bodyAppUid === 'string' && bodyAppUid.length > 0
? bodyAppUid
@@ -108,6 +122,26 @@ export class AppController extends PuterController {
});
}
// Access tokens (and any other non-user/non-app identity,
// e.g. asset tokens) are not allowed to report opens —
// they're shared / scoped credentials and shouldn't drive
// analytics counters.
if (isAccessTokenActor(actor)) {
throw new HttpError(
403,
'Access tokens cannot report app opens',
{ legacyCode: 'forbidden' },
);
}
if (isAppActor(actor) && app_uid !== actorAppUid) {
throw new HttpError(
403,
'App actors can only report opens for their own app',
{ legacyCode: 'forbidden' },
);
}
const app = await this.appStore.getByUid(app_uid);
if (!app)
throw new HttpError(404, 'App not found', {
@@ -153,6 +187,7 @@ export class AppController extends PuterController {
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
},
async (req, res) => {
const raw = req.params.name;
@@ -37,7 +37,9 @@ import type { EventClient } from '../../clients/event/EventClient.js';
import type { Actor } from '../../core/actor.js';
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 ────────────────────────────────────────────────────
@@ -72,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) => {
@@ -84,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>(
@@ -794,6 +800,65 @@ describe('AuthController.handleLogout', () => {
});
});
describe('AuthController account-lifecycle route gating', () => {
const routeOptions = (method: string, path: string) => {
const proto = Object.getPrototypeOf(controller) as {
__puterRoutes?: Array<{
method: string;
path: string;
options?: Record<string, unknown>;
}>;
};
const route = (proto.__puterRoutes ?? []).find(
(r) =>
r.method.toLowerCase() === method.toLowerCase() &&
r.path === path,
);
expect(route, `route ${method} ${path} not found`).toBeDefined();
return route!.options ?? {};
};
it('POST /logout requires a human user actor', () => {
const opts = routeOptions('post', '/logout');
expect(opts.requireUserActor).toBe(true);
expect(opts.antiCsrf).toBe(true);
});
it('GET /get-anticsrf-token requires a human user actor', () => {
const opts = routeOptions('get', '/get-anticsrf-token');
expect(opts.requireUserActor).toBe(true);
});
it('requireUserActorGate rejects app-under-user and access-token actors', () => {
const gate = requireUserActorGate();
const run = (actor: Partial<Actor>) =>
new Promise<unknown>((resolve) => {
gate(
{ actor } as never,
{} as never,
(err?: unknown) => resolve(err),
);
});
return (async () => {
const appActor = await run({
user: { uuid: 'u1' },
app: { uid: 'app-1' },
} as Partial<Actor>);
expect(appActor).toMatchObject({ statusCode: 403 });
const tokenActor = await run({
user: { uuid: 'u1' },
accessToken: { uid: 'tok-1' },
} as Partial<Actor>);
expect(tokenActor).toMatchObject({ statusCode: 403 });
const human = await run({ user: { uuid: 'u1' } } as Partial<Actor>);
expect(human).toBeUndefined();
})();
});
});
// ── Token grants: user → user / app / group ─────────────────────────
describe('AuthController grant flows', () => {
@@ -1223,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();
@@ -1548,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) ─────
@@ -1594,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(
@@ -2556,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 ─────────────────────────────────────
@@ -3597,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 () => {
+139 -34
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
@@ -764,7 +793,7 @@ export class AuthController extends PuterController {
// -- Logout ------------------------------------------------------
@Post('/logout', {
requireAuth: true,
requireUserActor: true,
allowUnconfirmed: true,
antiCsrf: true,
})
@@ -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(
@@ -1634,7 +1673,9 @@ export class AuthController extends PuterController {
// -- Anti-CSRF token generation ----------------------------------
@Get('/get-anticsrf-token', {
requireAuth: true,
// Anti-CSRF tokens are only consumed by `requireUserActor` routes,
// so issuance is scoped to the same actor kind for consistency.
requireUserActor: true,
allowUnconfirmed: true,
})
async handleGetAntiCsrfToken(req: Request, res: Response): Promise<void> {
@@ -2138,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',
});
@@ -2173,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,
@@ -2210,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];
@@ -2231,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 });
}
@@ -2335,8 +2401,8 @@ export class AuthController extends PuterController {
}
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = 1 WHERE `uuid` = ?',
[user.uuid],
'UPDATE `user` SET `otp_enabled` = ? WHERE `uuid` = ?',
[this.clients.db.booleanValue(true), user.uuid],
);
await this.stores.user.invalidateById(user.id);
@@ -2371,8 +2437,8 @@ export class AuthController extends PuterController {
});
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = 0, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
[user.uuid],
'UPDATE `user` SET `otp_enabled` = ?, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
[this.clients.db.booleanValue(false), user.uuid],
);
await this.stores.user.invalidateById(user.id);
@@ -2468,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({});
}
@@ -2497,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({});
}
@@ -2789,7 +2861,6 @@ export class AuthController extends PuterController {
router.post(
'/auth/migrate-token',
{
subdomain: 'api',
rateLimit: {
scope: 'migrate-token',
limit: 20,
@@ -2804,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) {
@@ -2821,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';
@@ -33,7 +34,7 @@ import type { DriverController } from './DriverController.js';
// The DriverController under test is the same instance the live request
// pipeline uses, so its iface→driver registry is populated from real
// drivers (puter-kvstore, puter-apps, puter-subdomains, …). The HTTP
// handlers (`#handleCall`, `#handleListInterfaces`, `#handleXd`) are
// handlers (`#handleCall`, `#handleListInterfaces`) are
// private — we exercise the public lookup API (`resolve` / list / get
// default) which the handlers themselves delegate to.
@@ -116,7 +117,7 @@ describe('DriverController.resolve', () => {
});
});
// ── Route handlers (#handleCall, #handleListInterfaces, #handleXd) ─
// ── Route handlers (#handleCall, #handleListInterfaces) ─────────────
// The handlers are private class fields. We capture references to them by
// invoking `registerRoutes` with a fake router whose `post`/`get` save the
@@ -189,10 +190,7 @@ const makeRes = (): MockRes => {
return res;
};
const makeReq = (
body: Record<string, unknown> = {},
actor?: Actor,
): Request =>
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
({
body,
actor,
@@ -338,11 +336,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
res as unknown as Response,
() => {},
),
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
success: boolean;
@@ -374,11 +368,9 @@ describe('DriverController.#handleCall (via captured router)', () => {
// Register it through the controller's private map by re-running
// its #buildIfaceMap path: easier to just stash it into the
// existing iface map directly via TypeScript-defeating cast.
const internalDrivers = (
controller as unknown as {
['#drivers']: Map<string, Map<string, unknown>>;
}
);
const internalDrivers = controller as unknown as {
['#drivers']: Map<string, Map<string, unknown>>;
};
// Access the actual private slot via the well-known getter
// pattern doesn't work for `#`-private fields; instead, re-run
// registerRoutes after stashing on the bag — but that's already
@@ -413,13 +405,12 @@ describe('DriverController.#handleCall (via captured router)', () => {
>;
const original = kv.set;
kv.set = async function (...args: unknown[]) {
const { Context } = await import(
'../../core/context.js'
);
const { Context } = await import('../../core/context.js');
Context.set('driverMetadata', { providerUsed: 'kv-direct' });
return (
original as (...x: unknown[]) => Promise<unknown>
).apply(this, args);
return (original as (...x: unknown[]) => Promise<unknown>).apply(
this,
args,
);
};
try {
const res = makeRes();
@@ -432,11 +423,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
res as unknown as Response,
() => {},
),
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
metadata?: Record<string, unknown>;
@@ -474,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(() => {
@@ -497,31 +607,11 @@ describe('DriverController.#handleListInterfaces', () => {
});
});
describe('DriverController.#handleXd', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
it('serves the iframe-bridge HTML with text/html content-type', () => {
const res = makeRes();
routes['GET /xd'](
makeReq(),
res as unknown as Response,
() => {},
);
expect(res.contentType).toBe('text/html');
expect(res.sentBody).toMatch(/<!DOCTYPE html>/);
expect(res.sentBody).toMatch(/\/drivers\/call/);
});
});
describe('DriverController.registerRoutes', () => {
it('registers POST /call, GET /list-interfaces, and GET /xd', () => {
it('registers POST /call and GET /list-interfaces', () => {
const routes = captureRoutes(controller);
expect(routes['POST /call']).toBeInstanceOf(Function);
expect(routes['GET /list-interfaces']).toBeInstanceOf(Function);
expect(routes['GET /xd']).toBeInstanceOf(Function);
});
});
@@ -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';
@@ -26,8 +27,6 @@ import {
checkDriverRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { PermissionService } from '../../services/permission/PermissionService.js';
import type { WithLifecycle } from '../../types';
import type { DriverMeta } from '../../drivers/meta.js';
import {
isDriverStreamResult,
@@ -35,120 +34,13 @@ import {
resolveDriverMethodConcurrent,
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';
type DriverInstance = WithLifecycle & Record<string, unknown>;
// -- /drivers/xd payload ---------------------------------------------
//
// Self-contained HTML/JS shipped to the iframe consumer. Listens for
// postMessage events shaped as `{ id, interface, method, params }`,
// forwards to `/drivers/call`, and posts `{ id, result }` back to the
// originating window.
//
// Wire-shape note: the postMessage uses `params` (puter-js's historical
// name) but `/drivers/call` expects `args`; this bridge translates.
const XD_SCRIPT = /* js */ `
(function () {
const call = async ({ interface_name, method_name, params }) => {
const response = await fetch('/drivers/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: interface_name,
method: method_name,
args: params,
}),
});
return await response.json();
};
const fcall = async ({ interface_name, method_name, params }) => {
const form = new FormData();
form.append('interface', interface_name);
form.append('method', method_name);
for (const k in params) {
form.append(k, params[k]);
}
const response = await fetch('/drivers/call', {
method: 'POST',
body: form,
});
return await response.json();
};
window.addEventListener('message', async (event) => {
const { id, interface: iface, method, params } = event.data || {};
let has_file = false;
for (const k in params) {
if (params[k] instanceof File) {
has_file = true;
break;
}
}
const result = has_file
? await fcall({ interface_name: iface, method_name: method, params })
: await call({ interface_name: iface, method_name: method, params });
if (event.source) {
event.source.postMessage({ id, result }, event.origin);
}
});
})();
`;
const XD_HTML = `<!DOCTYPE html>
<html>
<head>
<title>Puter Driver API</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
${XD_SCRIPT}
});
</script>
</head>
<body></body>
</html>`;
// -- Controller ------------------------------------------------------
/**
* Routes driver RPC calls through a unified HTTP surface.
*
* - `POST /drivers/call` — invoke `<iface>.<method>(args)` on the
* registered driver after a per-actor permission + rate-limit check.
* Stream-shaped results are piped directly; everything else is
* returned as JSON.
* - `GET /drivers/list-interfaces` — enumerate registered driver
* interfaces with their default + alternate implementations.
* - `GET /drivers/xd` — legacy iframe bridge; serves an HTML page that
* proxies `postMessage` RPCs to `/drivers/call` on the same origin.
*
* Holds an internal iface → driverName → instance map built at
* construction time from `this.drivers`. Extensions that register
* additional drivers end up in that bag before this controller is
* instantiated, so they show up here automatically.
*/
/**
* Catch-all upstream-error translator for the driver boundary.
*
* Drivers that wrap a third-party SDK (OpenAI, Anthropic, etc.) often
* let the SDK's own error class bubble — those carry an HTTP `.status`
* but are plain `Error` subclasses, not `HttpError`s, so they would
* otherwise hit the global error handler as unexpected 500s and page
* PagerDuty. Repackage them with `upstream_*` legacy codes so the
* alarm gate (server.ts) treats them as upstream failures and only
* pages on the two we actually care about (rate-limit / auth).
*
* Status extraction covers the shapes we've seen in the wild:
* - `.status` / `.statusCode` (OpenAI / Anthropic / Together / Google GenAI)
* - `.response.status` (Replicate's `ApiError`)
* - `.$metadata.httpStatusCode` (AWS SDK v3, e.g. Polly)
* - status sniffed from the message string (last resort, for providers
* that re-wrap their SDK error in `new Error(msg)` and lose the field)
*
* `HttpError`s thrown by drivers pass through untouched.
*/
const extractUpstreamStatus = (e: {
status?: number;
statusCode?: number;
@@ -273,11 +165,6 @@ export class DriverController extends PuterController {
{ subdomain: 'api', requireAuth: true },
this.#handleListInterfaces,
);
router.get(
'/xd',
{ subdomain: 'api', requireAuth: true },
this.#handleXd,
);
}
// -- Handlers ----------------------------------------------------
@@ -336,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,
@@ -439,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);
@@ -489,11 +462,6 @@ export class DriverController extends PuterController {
res.json(out);
};
#handleXd = (_req: Request, res: Response): void => {
res.type('text/html');
res.send(XD_HTML);
};
// -- Internals ---------------------------------------------------
#buildIfaceMap(): void {
@@ -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!;
@@ -953,6 +1012,82 @@ describe('FSController.mkdirEntry', () => {
expect(body.isDir).toBe(true);
});
it('dedupes an existing directory when dedupe_name is true', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const target = `/${username}/Documents/hello`;
await withActor(actor, () =>
controller.mkdirEntry(
makeReq({
body: { path: target },
actor,
}),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.mkdirEntry(
makeReq({
body: { path: target, dedupe_name: true },
actor,
}),
res,
),
);
const body = captured.body as {
path: string;
name: string;
isDir: boolean;
};
expect(body.path).toBe(`/${username}/Documents/hello (1)`);
expect(body.name).toBe('hello (1)');
expect(body.isDir).toBe(true);
expect(
await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents/hello (1)`,
),
).toMatchObject({ isDir: true });
});
it('requires parent write when deduping an existing directory', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appUid = `app-mkdir-${uuidv4()}`;
const appActor: Actor = { ...userActor, app: { uid: appUid } };
const target = `/${username}/AppData/${appUid}`;
await withActor(userActor, () =>
controller.mkdirEntry(
makeReq({
body: { path: target },
actor: userActor,
}),
makeRes().res,
),
);
await expect(
withActor(appActor, () =>
controller.mkdirEntry(
makeReq({
body: { path: target, dedupe_name: true },
actor: appActor,
}),
makeRes().res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
expect(
await server.stores.fsEntry.getEntryByPath(
`/${username}/AppData/${appUid} (1)`,
),
).toBeNull();
});
it('expands ~/ in the path to the user home', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
+22 -3
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)
@@ -1039,13 +1045,15 @@ export class FSController extends PuterController {
legacyCode: 'bad_request',
});
const dedupeName =
this.#toBoolean(body.dedupe_name ?? body.dedupeName) ?? false;
await this.#assertCanCreate(actor, path);
if (dedupeName) await this.#assertCanDedupeCreate(actor, path);
const entry = await this.services.fs.mkdir(userId, {
path,
overwrite: this.#toBoolean(body.overwrite) ?? false,
dedupeName:
this.#toBoolean(body.dedupe_name ?? body.dedupeName) ?? false,
dedupeName,
createMissingParents:
this.#toBoolean(
body.create_missing_parents ??
@@ -1310,6 +1318,17 @@ export class FSController extends PuterController {
await this.#assertAccess(actor, parentForCheck, 'write');
}
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
if (!existing) return;
const parent = pathPosix.dirname(targetPath);
await this.#assertAccess(
actor,
parent === '/' ? targetPath : parent,
'write',
);
}
async #assertAccess(
actor: Actor,
path: string,
@@ -206,6 +206,78 @@ describe('LegacyFSController.mkdir', () => {
expect(fetched?.isDir).toBe(true);
});
it('dedupes an existing directory when dedupe_name is true', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const parent = `/${username}/Documents`;
await withActor(actor, () =>
controller.mkdir(
makeReq({
body: { parent, path: 'hello' },
actor,
}),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.mkdir(
makeReq({
body: { parent, path: 'hello', dedupe_name: true },
actor,
}),
res,
),
);
const body = captured.body as Record<string, unknown>;
expect(body).toMatchObject({
path: `${parent}/hello (1)`,
name: 'hello (1)',
is_dir: true,
});
expect(
await server.stores.fsEntry.getEntryByPath(`${parent}/hello (1)`),
).toMatchObject({ isDir: true });
});
it('requires parent write when deduping an existing directory', async () => {
const { actor: userActor } = await makeUser();
const username = userActor.user!.username!;
const appUid = `app-legacy-mkdir-${uuidv4()}`;
const appActor: Actor = { ...userActor, app: { uid: appUid } };
const parent = `/${username}/AppData`;
await withActor(userActor, () =>
controller.mkdir(
makeReq({
body: { parent, path: appUid },
actor: userActor,
}),
makeRes().res,
),
);
await expect(
withActor(appActor, () =>
controller.mkdir(
makeReq({
body: { parent, path: appUid, dedupe_name: true },
actor: appActor,
}),
makeRes().res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
expect(
await server.stores.fsEntry.getEntryByPath(
`${parent}/${appUid} (1)`,
),
).toBeNull();
});
it("rejects writing into another user's home with a 4xx", async () => {
const a = await makeUser();
const b = await makeUser();
@@ -1010,6 +1082,76 @@ describe('LegacyFSController.sign', () => {
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses an app actor signing for a different app (403)', async () => {
// An app-under-user actor may only mint a token for its own app;
// requesting a different app's UID is rejected.
const { actor: userActor } = await makeUser();
const targetApp = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `victim-${uuidv4()}`,
title: 'Victim app',
index_url: 'https://example.test/victim.html',
},
{ ownerUserId: userActor.user!.id! },
);
const attackerActor: Actor = {
...userActor,
app: { uid: `attacker-${uuidv4()}` },
};
const { res } = makeRes();
await expect(
withActor(attackerActor, () =>
controller.sign(
makeReq({
body: {
items: [{}],
app_uid: targetApp.uid,
},
actor: attackerActor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
});
it('lets an app actor sign for its own app', async () => {
const { actor: userActor } = await makeUser();
const ownApp = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `self-${uuidv4()}`,
title: 'Self app',
index_url: 'https://example.test/self.html',
},
{ ownerUserId: userActor.user!.id! },
);
const appActor: Actor = { ...userActor, app: { uid: ownApp.uid } };
const { res, captured } = makeRes();
await withActor(appActor, () =>
controller.sign(
makeReq({
body: { items: [{}], app_uid: ownApp.uid },
actor: appActor,
}),
res,
),
);
const body = captured.body as { token?: string };
expect(typeof body.token).toBe('string');
});
});
// ── writeFile (validation paths) ────────────────────────────────────
@@ -1089,6 +1231,85 @@ describe('LegacyFSController.openItem', () => {
});
});
// ── openItem: write_url stripping for read-only callers ─────────────
//
// Mirrors `/sign` and `/readdir`, which already strip `write_url` when the
// caller only proved read. Without these gates, /open_item handed out a
// valid write signature to read-only sharees — `/writeFile`'s own ACL
// re-check would still reject the write, but the leak shape (a signed
// write URL escaping the access boundary) is the same one those other
// endpoints already defend against.
describe('LegacyFSController.openItem (write_url stripping)', () => {
beforeAll(() => {
(
controller as unknown as { config: { api_base_url?: string } }
).config.api_base_url = 'http://api.test.local';
});
it('returns write_url for the owner (who has write)', async () => {
const { actor } = await makeUser();
const target = `/${actor.user!.username}/Documents/owned.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: target }, actor }),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.openItem(
makeReq({ body: { path: target }, actor }),
res,
),
);
const body = captured.body as {
signature: { read_url?: string; write_url?: string };
};
expect(body.signature.read_url).toBeDefined();
expect(body.signature.write_url).toBeDefined();
expect(body.signature.write_url).toContain('writeFile');
});
it('strips write_url for a read-only sharee (the fix)', async () => {
const victim = await makeUser();
const attacker = await makeUser();
const target = `/${victim.actor.user!.username}/Documents/shared-ro.txt`;
await withActor(victim.actor, () =>
controller.touch(
makeReq({ body: { path: target }, actor: victim.actor }),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(target);
await server.services.permission.grantUserUserPermission(
victim.actor,
attacker.actor.user!.username!,
`fs:${entry!.uuid}:read`,
{},
);
const { res, captured } = makeRes();
await withActor(attacker.actor, () =>
controller.openItem(
makeReq({
body: { uid: entry!.uuid },
actor: attacker.actor,
}),
res,
),
);
const body = captured.body as {
signature: { read_url?: string; write_url?: string };
};
// Sharee still gets read_url (they have read).
expect(body.signature.read_url).toBeDefined();
// …but write_url is stripped (they don't have write).
expect(body.signature.write_url).toBeUndefined();
});
});
// ── requestAppRootDir ───────────────────────────────────────────────
describe('LegacyFSController.requestAppRootDir', () => {
@@ -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,
},
@@ -427,17 +433,33 @@ export class LegacyFSController extends PuterController {
const normalizedTarget = targetPath.startsWith('/')
? targetPath
: `/${targetPath}`;
const dedupeName =
getBoolean(body, 'dedupe_name', 'change_name') ?? false;
await assertCanCreate(
this.services.acl,
this.services.fs,
actor,
normalizedTarget,
);
if (dedupeName) {
const existing =
await this.stores.fsEntry.getEntryByPath(normalizedTarget);
if (existing) {
const parent = pathPosix.dirname(normalizedTarget);
await assertAccess(
this.services.acl,
this.services.fs,
actor,
parent === '/' ? normalizedTarget : parent,
'write',
);
}
}
const entry = await this.services.fs.mkdir(userId, {
path: targetPath,
overwrite: getBoolean(body, 'overwrite') ?? false,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
dedupeName,
createMissingParents:
getBoolean(
body,
@@ -1336,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)
@@ -1391,6 +1420,20 @@ export class LegacyFSController extends PuterController {
'read',
);
// Downgrade the envelope when the caller only proved read.
// `/writeFile`'s ACL re-check would still block the write, but
// returning `write_url` to a read-only caller is the same
// privilege-leak shape that `/sign` and `/readdir` strip.
const writeOk = await this.services.acl.check(
actor,
{
path: entry.path,
resolveAncestors: () =>
this.services.fs.getAncestorChain(entry.path),
},
'write',
);
const suggested =
(await this.services.suggestedApps?.getSuggestedApps({
name: entry.name,
@@ -1417,7 +1460,13 @@ export class LegacyFSController extends PuterController {
}
const signingCfg = signingConfigFromAppConfig(this.config);
const signature = { ...signEntry(entry, signingCfg), path: entry.path };
const signed = signEntry(entry, signingCfg);
const signature = writeOk
? { ...signed, path: entry.path }
: (() => {
const { write_url: _, ...rest } = signed;
return { ...rest, path: entry.path };
})();
res.json({
signature,
token,
@@ -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;
@@ -165,7 +165,7 @@ export class StaticPagesController extends PuterController {
const domain = this.config.domain ?? req.hostname;
const origin = `${req.protocol}://${domain}`;
const apps = (await this.clients.db.read(
'SELECT `name` FROM `apps` WHERE `approved_for_listing` = 1',
`SELECT \`name\` FROM \`apps\` WHERE \`approved_for_listing\` = ${this.clients.db.booleanLiteral(true)}`,
)) as Array<{ name: string }>;
const urls = [
`<url><loc>${req.protocol}://docs.${domain}/</loc></url>`,
@@ -252,7 +252,7 @@ export class StaticPagesController extends PuterController {
const [dupe] = (await this.clients.db.read(
`SELECT EXISTS(
SELECT 1 FROM \`user\` WHERE (\`email\` = ? OR \`clean_email\` = ?)
AND \`email_confirmed\` = 1
AND \`email_confirmed\` = ${this.clients.db.booleanLiteral(true)}
AND \`password\` IS NOT NULL
) AS email_exists`,
[user.email, cleanEmail],
@@ -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,
@@ -243,7 +243,9 @@ export async function resolveOwnedAppForHostedSite(opts: {
const uniqueCandidates = [...new Set(urlCandidates)];
if (uniqueCandidates.length === 0) return null;
const privateFilter = opts.requirePrivate ? 'AND `is_private` = 1 ' : '';
const privateFilter = opts.requirePrivate
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
: '';
const placeholders = uniqueCandidates.map(() => '?').join(', ');
const rows = await opts.db.read(
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
@@ -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,8 +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 type {
IChatCompleteResult,
IChatModel,
@@ -56,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
@@ -276,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 &&
@@ -397,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;
}
}
@@ -795,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 = {
@@ -869,6 +918,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const minimax = providers['minimax'];
const minimaxKey = readKey(minimax);
if (minimaxKey) {
this.#providers['minimax'] = new MiniMaxProvider(
{
apiKey: minimaxKey,
apiBaseUrl: minimax?.apiBaseUrl as string | undefined,
},
metering,
);
}
const zai = providers['zai'];
const zaiKey = readKey(zai);
if (zaiKey) {
@@ -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,15 +229,21 @@ export class ClaudeProvider implements IChatProvider {
reasoningEffort: requestedReasoningEffort,
maxTokens: max_tokens,
});
// Opus 4.7 errors 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 isOpus47 = modelUsed.id === 'claude-opus-4-7';
const resolvedTemperature = isOpus47
const omitsTemperature = [
'claude-fable-5',
'claude-opus-4-7',
'claude-opus-4-8',
].includes(modelUsed.id);
const resolvedTemperature = omitsTemperature
? undefined
: thinkingConfig
? 1
: (temperature ?? 0);
const supportsEffort = [
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
@@ -486,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,66 @@ 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',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-01',
release_date: '2026-05-28',
aliases: [
'claude-opus',
'claude-opus-latest',
'claude-opus-4-8-latest',
'claude-opus-4.8',
'claude-opus-4-8',
'anthropic/claude-opus-4-8',
],
name: 'Claude Opus 4.8',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 500,
ephemeral_5m_input_tokens: 500 * 1.25,
ephemeral_1h_input_tokens: 500 * 2,
cache_read_input_tokens: 500 * 0.1,
output_tokens: 2500,
},
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-opus-4-7',
id: 'claude-opus-4-7',
@@ -30,8 +90,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
knowledge: '2026-01',
release_date: '2026-04-16',
aliases: [
'claude-opus',
'claude-opus-latest',
'claude-opus-4-7-latest',
'claude-opus-4.7',
'claude-opus-4-7',
@@ -0,0 +1,355 @@
/*
* 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,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { MINIMAX_MODELS } from './models.js';
import { MiniMaxProvider } from './MiniMaxProvider.js';
const { createMock, openAICtor } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
});
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.chat = { completions: { create: createMock } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () => {
const provider = new MiniMaxProvider(
{ apiKey: 'test-key' },
server.services.metering,
);
return { provider };
};
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
async *[Symbol.asyncIterator]() {
for (const item of items) {
yield item;
}
},
});
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('MiniMaxProvider construction', () => {
it('points the OpenAI SDK at the MiniMax base URL with the configured key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://api.minimax.io/v1',
});
});
it('allows overriding the MiniMax API base URL', () => {
new MiniMaxProvider(
{
apiKey: 'test-key',
apiBaseUrl: 'https://example.test/v1',
},
server.services.metering,
);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://example.test/v1',
});
});
});
describe('MiniMaxProvider model catalog', () => {
it('returns minimax-m2.7 as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('minimax-m2.7');
});
it('exposes the static MINIMAX_MODELS list verbatim from models()', () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(MINIMAX_MODELS);
});
it('list() flattens canonical ids and aliases', () => {
const { provider } = makeProvider();
const ids = provider.list();
for (const model of MINIMAX_MODELS) {
expect(ids).toContain(model.id);
for (const alias of model.aliases ?? []) {
expect(ids).toContain(alias);
}
}
expect(ids).toContain('minimax-m2.7');
expect(ids).toContain('MiniMax-M2.7');
expect(ids).toContain('minimax/minimax-m2.7');
});
it('uses MiniMax completion-token limits instead of the context window', () => {
for (const model of MINIMAX_MODELS) {
expect(model.context).toBe(204_800);
expect(model.max_tokens).toBe(196_608);
}
});
});
describe('MiniMaxProvider.complete request shape', () => {
const baseCompletion = {
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('sends the case-sensitive upstream MiniMax model id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('MiniMax-M2.7');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
it('resolves the upstream-cased alias to the canonical API model', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'MiniMax-M2.7-highspeed',
messages: [{ role: 'user', content: 'hello' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe(
'MiniMax-M2.7-highspeed',
);
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'minimax:minimax-m2.7-highspeed',
expect.any(Object),
);
});
it('passes standard chat completion options through', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const tools = [
{
type: 'function',
function: {
name: 'lookup',
parameters: { type: 'object', properties: {} },
},
},
];
await withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'hi' }],
tools,
tool_choice: 'auto',
max_tokens: 256,
temperature: 0.4,
top_p: 0.9,
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.tools).toBe(tools);
expect(args.tool_choice).toBe('auto');
expect(args.max_tokens).toBe(256);
expect(args.temperature).toBe(0.4);
expect(args.top_p).toBe(0.9);
});
it('clamps oversized max_tokens to the MiniMax completion limit', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'minimax-m2.1',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 200_000,
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('MiniMax-M2.1');
expect(args.max_tokens).toBe(196_608);
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
);
const [nonStreamArgs] = createMock.mock.calls[0]!;
expect(nonStreamArgs.stream).toBe(false);
expect('stream_options' in nonStreamArgs).toBe(false);
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
);
const [streamArgs] = createMock.mock.calls[1]!;
expect(streamArgs.stream).toBe(true);
expect(streamArgs.stream_options).toEqual({ include_usage: true });
});
});
describe('MiniMaxProvider.complete output and metering', () => {
it('returns the first choice and records MiniMax usage with model costs', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 10 },
},
});
const result = await withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(result).toMatchObject({
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
});
expect((result as { usage: unknown }).usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 10,
});
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
cached_tokens: 10,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('minimax:minimax-m2.7');
expect(overrides).toEqual({
prompt_tokens: 100 * 30,
completion_tokens: 50 * 120,
cached_tokens: 10 * 6,
});
});
it('rethrows errors raised by the OpenAI client unchanged', async () => {
const { provider } = makeProvider();
const apiError = new Error('MiniMax exploded');
createMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() =>
provider.complete({
model: 'minimax-m2.7',
messages: [{ role: 'user', content: 'boom' }],
}),
),
).rejects.toBe(apiError);
expect(recordSpy).not.toHaveBeenCalled();
});
});
describe('MiniMaxProvider.checkModeration', () => {
it('throws because MiniMax provider does not implement moderation', () => {
const { provider } = makeProvider();
expect(() => provider.checkModeration('anything')).toThrow(
/not implemented/i,
);
});
});
@@ -0,0 +1,135 @@
/*
* 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 { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { MINIMAX_MODELS } from './models.js';
type MiniMaxConfig = {
apiKey: string;
apiBaseUrl?: string;
};
export class MiniMaxProvider implements IChatProvider {
#openai: OpenAI;
#meteringService: MeteringService;
#defaultModel = 'minimax-m2.7';
constructor(config: MiniMaxConfig, meteringService: MeteringService) {
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL: config.apiBaseUrl ?? 'https://api.minimax.io/v1',
});
this.#meteringService = meteringService;
}
getDefaultModel() {
return this.#defaultModel;
}
models() {
return MINIMAX_MODELS;
}
list() {
const modelIds: string[] = [];
for (const model of this.models()) {
modelIds.push(model.id);
if (model.aliases) {
modelIds.push(...model.aliases);
}
}
return modelIds;
}
async complete({
messages,
stream,
model,
tools,
tool_choice,
max_tokens,
temperature,
top_p,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
messages = await OpenAIUtil.process_input_messages(messages);
const requestedMaxTokens = max_tokens ?? 1000;
const completion = await this.#openai.chat.completions.create({
messages,
model: modelUsed.apiModel,
...(tools ? { tools } : {}),
...(tool_choice !== undefined ? { tool_choice } : {}),
max_tokens: Math.min(requestedMaxTokens, modelUsed.max_tokens),
...(temperature !== undefined ? { temperature } : {}),
...(top_p !== undefined ? { top_p } : {}),
stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
} as ChatCompletionCreateParams);
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const trackedUsage = usage
? OpenAIUtil.extractMeteredUsage(usage)
: {
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
};
const costsOverride = Object.fromEntries(
Object.entries(trackedUsage).map(([key, value]) => {
return [key, value * Number(modelUsed.costs[key] ?? 0)];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
`minimax:${modelUsed.id}`,
costsOverride,
);
return trackedUsage;
},
stream,
completion,
});
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
}
}
@@ -0,0 +1,77 @@
/*
* 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';
import { usdPerMToken } from '../../utils/pricing.js';
const CONTEXT_WINDOW = 204_800;
const MAX_OUTPUT_TOKENS = 196_608;
type MiniMaxChatModel = IChatModel & {
apiModel: string;
};
const minimaxModel = (
apiModel: string,
name: string,
costs: IChatModel['costs'],
): MiniMaxChatModel => {
const id = apiModel.toLowerCase();
return {
puterId: `minimax:minimax/${id}`,
id,
apiModel,
name,
aliases: [`minimax/${id}`, apiModel, `minimax/${apiModel}`],
modalities: { input: ['text'], output: ['text'] },
open_weights: false,
tool_call: true,
context: CONTEXT_WINDOW,
max_tokens: MAX_OUTPUT_TOKENS,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs,
};
};
// Hardcoded from MiniMax OpenAI-compatible API docs and pay-as-you-go pricing:
// https://platform.minimax.io/docs/api-reference/text-openai-api
// https://platform.minimax.io/docs/guides/pricing-paygo
export const MINIMAX_MODELS: MiniMaxChatModel[] = [
minimaxModel('MiniMax-M2.7', 'MiniMax M2.7', usdPerMToken(0.3, 1.2, 0.06)),
minimaxModel(
'MiniMax-M2.7-highspeed',
'MiniMax M2.7 Highspeed',
usdPerMToken(0.6, 2.4, 0.06),
),
minimaxModel('MiniMax-M2.5', 'MiniMax M2.5', usdPerMToken(0.3, 1.2, 0.03)),
minimaxModel(
'MiniMax-M2.5-highspeed',
'MiniMax M2.5 Highspeed',
usdPerMToken(0.6, 2.4, 0.03),
),
minimaxModel('MiniMax-M2.1', 'MiniMax M2.1', usdPerMToken(0.3, 1.2, 0.03)),
minimaxModel(
'MiniMax-M2.1-highspeed',
'MiniMax M2.1 Highspeed',
usdPerMToken(0.6, 2.4, 0.03),
),
minimaxModel('MiniMax-M2', 'MiniMax M2', usdPerMToken(0.3, 1.2, 0.03)),
];
@@ -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',
@@ -21,8 +21,8 @@ import { Together } from 'together-ai';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { kv } from '../../../../util/kvSingleton.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
const TOGETHER_AI_CHAT_COST_MAP = {
prompt_tokens: 'input',
@@ -51,7 +51,9 @@ export class TogetherAIProvider implements IChatProvider {
let models: IChatModel[] | undefined = kv.get(this.#kvKey);
if (models) return models;
const apiModels = await this.#together.models.list();
const apiModels = await this.#together.models.list({
query: { serverless: 'true' },
});
models = [];
for (const model of apiModels) {
if (
@@ -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',
@@ -18,22 +18,10 @@
*/
import type { IChatModel } from '../../types.js';
import { usdPerMToken } from '../../utils/pricing.js';
const CENTS_PER_USD = 100;
const MTOK = 1_000_000;
const K = 1_000;
const usdPerMToken = (
inputUsd: number,
outputUsd: number,
cachedInputUsd = 0,
) => ({
tokens: MTOK,
prompt_tokens: inputUsd * CENTS_PER_USD,
completion_tokens: outputUsd * CENTS_PER_USD,
cached_tokens: cachedInputUsd * CENTS_PER_USD,
});
const textModel = (
id: string,
name: string,
@@ -0,0 +1,40 @@
/**
* 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 { ModelCost } from '../types.js';
const CENTS_PER_USD = 100;
const MTOK = 1_000_000;
/**
* Builds a `costs` block (currency `usd-cents`, per million tokens) from
* per-million-token USD prices. Providers list pricing in USD/MTok, so this
* keeps the source numbers readable while emitting the cents-based shape the
* driver expects.
*/
export const usdPerMToken = (
inputUsd: number,
outputUsd: number,
cachedReadUsd = 0,
): ModelCost => ({
tokens: MTOK,
prompt_tokens: inputUsd * CENTS_PER_USD,
completion_tokens: outputUsd * CENTS_PER_USD,
cached_tokens: cachedReadUsd * CENTS_PER_USD,
});
@@ -146,7 +146,10 @@ export class ImageGenerationDriver extends PuterDriver {
await this.#assertWriteAccess(actor, resolvedOutputPath);
}
let modelId = args.model?.trim().toLowerCase();
let modelId =
typeof args.model === 'string'
? args.model.trim().toLowerCase()
: undefined;
let intendedProvider =
args.provider ?? (Context.get('driverName') as string | undefined);
+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;
+83
View File
@@ -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/>.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { extension } from './extensions.ts';
import { clientsContainers } from './exports.ts';
/**
* The `extension.import('client')` proxy does NOT return `undefined` for
* clients that were never registered — it hands back a placeholder proxy that
* throws on property access. Extensions reaching for an OPTIONAL client (e.g.
* the ClickHouse analytics client, absent on plain self-hosts) must therefore
* probe for a real method behind a try/catch rather than trusting truthiness.
*
* These tests lock it so a future proxy change can't silently
* reintroduce the "truthy-but-throws" footgun.
*/
describe('extension.import("client") optional client access', () => {
const clients = extension.import('client') as Record<
string,
{ query?: unknown } | undefined
>;
afterEach(() => {
delete clientsContainers.notRegistered;
delete clientsContainers.optionalThing;
});
it('returns a truthy-but-throwing placeholder for an unregistered client', () => {
delete clientsContainers.notRegistered;
const placeholder = clients.notRegistered;
// Truthy — so a bare `if (clients.x)` check is NOT safe.
expect(placeholder).toBeTruthy();
// ...and accessing a method on it throws.
expect(() => (placeholder as { query: unknown }).query).toThrow();
});
it('the try/catch probe pattern reports absence safely', () => {
delete clientsContainers.optionalThing;
const probe = () => {
try {
return typeof clients.optionalThing?.query === 'function'
? clients.optionalThing
: null;
} catch {
return null;
}
};
expect(probe()).toBeNull();
});
it('the same probe returns a registered client exposing the method', () => {
const fake = { query: async () => undefined };
clientsContainers.optionalThing =
fake as unknown as (typeof clientsContainers)[string];
const probe = () => {
try {
return typeof clients.optionalThing?.query === 'function'
? clients.optionalThing
: null;
} catch {
return null;
}
};
expect(probe()).toBe(fake);
});
});
+5 -1
View File
@@ -56,11 +56,12 @@
"lorem-ipsum": "^2.0.8",
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.21.1",
"mysql2": "^3.22.4",
"nodemailer": "^8.0.7",
"openai": "^6.34.0",
"otpauth": "^9.2.4",
"parse-domain": "^8.2.2",
"pg": "^8.21.0",
"prompt-sync": "^4.2.0",
"replicate": "^1.0.0",
"sharp": "^0.34.5",
@@ -68,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"
@@ -77,9 +79,11 @@
"@types/busboy": "^1.5.4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.0",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
"chai": "^4.3.7",
"nodemon": "^3.1.0",
"pgmock": "^1.0.3",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vitest": "^4.0.14"
+38 -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.
@@ -601,6 +610,8 @@ export class PuterServer {
'Content-Type',
'Accept',
'Authorization',
'Cache-Control',
'Pragma',
'sentry-trace',
'baggage',
'Depth',
@@ -845,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) {
@@ -868,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
@@ -953,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;
}
}
@@ -32,6 +32,7 @@ const RECOMMENDED_APP_NAMES = [
'camera',
'music-player',
'recorder',
'browser',
'memos',
'word-processor',
'spreadsheet',
@@ -23,6 +23,8 @@ import { v4 as uuidv4 } from 'uuid';
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 {
@@ -75,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 ───────────────────────────────────
@@ -1181,6 +1197,24 @@ describe('AuthService (integration)', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('createWorkerAppToken refuses an app actor targeting a different app (403)', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { uid: `app-${uuidv4()}` },
} as Actor;
await expect(
authService.createWorkerAppToken(
actor,
`app-${uuidv4()}`,
'wk-x',
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
// ── Revocation flow ────────────────────────────────────────
it('revokeSession on a worker session — authenticate returns reauth.session_revoked', async () => {
@@ -1298,6 +1332,20 @@ describe('AuthService (integration)', () => {
expect(a).toBe(b);
expect(a).toMatch(/^app-/);
});
it.each([
'javascript:alert(document.domain)',
'data:text/html,<script>alert(1)</script>',
'file:///etc/passwd',
'vbscript:msgbox(1)',
])('throws 400 for non-http(s) scheme %s', async (origin) => {
// These parse fine via `new URL()` but must never become a
// bootstrap app `index_url` — that would be a stored XSS /
// code-execution vector when launched as `iframe.src`.
await expect(
authService.appUidFromOrigin(origin),
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('getUserAppToken', () => {
@@ -1347,6 +1395,67 @@ describe('AuthService (integration)', () => {
// same app session row.
expect(decodedFirst.session_uid).toBe(decodedSecond.session_uid);
});
// Delegation scope: a scoped actor (app-under-user or access-token)
// may only mint a token for its own app; only a root user session
// may request a token for an arbitrary app.
it('lets an app actor mint a token for its own app', async () => {
const user = await makeUser();
const ownApp = `app-${uuidv4()}`;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { uid: ownApp },
} as Actor;
const token = await authService.getUserAppToken(actor, ownApp);
const decoded = server.services.token.verify('auth', token) as {
app_uid: string;
};
expect(decoded.app_uid).toBe(ownApp);
});
it('refuses an app actor minting a token for a different app (403)', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { uid: `app-${uuidv4()}` },
} as Actor;
await expect(
authService.getUserAppToken(actor, `app-${uuidv4()}`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
it('refuses an access-token actor minting an app token (403)', async () => {
const user = await makeUser();
const issuer = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
accessToken: { uid: `tok-${uuidv4()}`, issuer, authorized: null },
} as Actor;
await expect(
authService.getUserAppToken(actor, `app-${uuidv4()}`),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
});
it('lets a root user session mint a token for any app', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const anyApp = `app-${uuidv4()}`;
const token = await authService.getUserAppToken(actor, anyApp);
const decoded = server.services.token.verify('auth', token) as {
app_uid: string;
};
expect(decoded.app_uid).toBe(anyApp);
});
});
describe('createAccessToken / revokeAccessToken', () => {
@@ -1449,6 +1558,213 @@ describe('AuthService (integration)', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
});
// Regression for the post-#1001 token-inval check: an
// app-under-user actor must be able to mint a token for a file
// inside its own AppData. Without the `app-owns-appdata`
// implicator the issuer-subset gate 403s these — even though
// ACLService already allows the equivalent fs.read via its own
// short-circuit. puter-js getReadURL is the canonical caller.
it('app-under-user actor can mint fs:<uuid>:read for a file inside its own AppData', async () => {
const user = await makeUser();
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[
appUid,
`n-${appUid}`,
`t-${appUid}`,
`https://${appUid}.example/`,
user.id,
],
);
const appDataPath = `/${user.username}/AppData/${appUid}`;
await server.services.fs.mkdir(user.id, {
path: appDataPath,
createMissingParents: true,
} as never);
const body = Buffer.from('hello');
await server.services.fs.write(user.id, {
fileMetadata: {
path: `${appDataPath}/note.txt`,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
} as never);
const fileEntry = await server.stores.fsEntry.getEntryByPath(
`${appDataPath}/note.txt`,
);
expect(fileEntry).not.toBeNull();
const appActor: Actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { id: 0, uid: appUid },
} as Actor;
const jwt = await authService.createAccessToken(appActor, [
[`fs:${fileEntry!.uuid}:read`],
]);
expect(typeof jwt).toBe('string');
});
// Negative side of the implicator: a file the user owns but
// that lives *outside* the app's AppData must still be
// rejected — the issuer-subset gate is the only thing
// preventing an authorized app from minting a token over
// arbitrary user-owned uuids.
it('app-under-user actor cannot mint fs:<uuid>:read for a user-owned file outside its AppData', async () => {
const user = await makeUser();
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[
appUid,
`n-${appUid}`,
`t-${appUid}`,
`https://${appUid}.example/`,
user.id,
],
);
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 appActor: Actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
app: { id: 0, uid: appUid },
} as Actor;
await expect(
authService.createAccessToken(appActor, [
[`fs:${fileEntry!.uuid}:read`],
]),
).rejects.toMatchObject({
statusCode: 403,
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', () => {
+96 -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,
@@ -357,6 +358,7 @@ export class AuthService extends PuterService {
legacyCode: 'bad_request',
});
}
this.#assertAppDelegationAllowed(actor, appUid);
const auth_id = this.#authIdFor(actor.user as UserRow);
const session = await this.stores.session.getOrCreateWorker(
actor.user.id,
@@ -384,6 +386,22 @@ export class AuthService extends PuterService {
return user.uuid;
}
/**
* Scope app token delegation by actor kind. An app-under-user or
* access-token actor is bound to a single app and may only mint a token
* for that same app; only a root user session may request a token for an
* arbitrary app (the GUI's app-launch delegation).
*/
#assertAppDelegationAllowed(actor: Actor, appUid: string): void {
if ((actor.app || actor.accessToken) && actor.app?.uid !== appUid) {
throw new HttpError(
403,
'Actor cannot mint a token for another app',
{ legacyCode: 'forbidden' },
);
}
}
/**
* Convert a jsonwebtoken-style `expiresIn` (seconds, or `'1h'`/`'30d'`)
* into an absolute unix-seconds timestamp for the session row. Returns
@@ -591,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 } = {},
@@ -1057,6 +1093,7 @@ export class AuthService extends PuterService {
throw new HttpError(403, 'Actor must be a user', {
legacyCode: 'forbidden',
});
this.#assertAppDelegationAllowed(actor, appUid);
// Request-context (IP / UA) isn't available on the Actor shape —
// the app row's `last_ip` / `last_user_agent` start NULL and get
@@ -1423,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', {
@@ -1439,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)
@@ -1447,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) {
@@ -1491,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,
@@ -1512,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
@@ -1521,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
@@ -1532,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 (?, ?, ?, ?, ?)',
[
@@ -1633,6 +1709,16 @@ export class AuthService extends PuterService {
#originFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
// A real web origin is always http(s). `new URL()` happily parses
// `javascript:`, `data:`, `file:`, `vbscript:`, etc.; if one of
// those slips through it ends up persisted as an app `index_url`
// (see AppStore.createFromOrigin) and later loaded as `iframe.src`
// — an XSS/code-execution primitive. Reject anything that isn't
// http(s) so the bootstrap path matches AppDriver's validateUrl
// allow-list.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
} catch {
return null;
@@ -1808,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 =
+137 -5
View File
@@ -206,6 +206,51 @@ export class FSService extends PuterService {
},
});
// -- app-owns-appdata -----------------------------------------
// Mirror of the ACLService short-circuit at ACLService.check:
// an app-under-user actor implicitly holds fs:<uuid>:* on any
// entry inside its own /<username>/AppData/<appUid> subtree.
// ACL paths (fs.read etc.) already accept these via that
// short-circuit, but permissionService.checkMany — used by
// createAccessToken's issuer-subset gate — bypasses ACL, so
// without this implicator an app can fs.read its appdata but
// can't mint a token for the same fs:<uuid>:read it just read.
permissions.registerImplicator({
id: 'app-owns-appdata',
shortcut: true,
matches: (permission: string): boolean => {
return (
permission.startsWith('fs:') ||
permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`) ||
permission.startsWith(
`${MANAGE_PERM_PREFIX}:${MANAGE_PERM_PREFIX}:fs:`,
)
);
},
check: async ({ actor, permission }): Promise<unknown> => {
if (!actor.app || actor.accessToken) return undefined;
const username = actor.user?.username;
const appUid = actor.app.uid;
if (!username || !appUid) return undefined;
const stripped = permission.replaceAll(
`${MANAGE_PERM_PREFIX}:`,
'',
);
const uid = PermissionUtil.split(stripped)[1];
if (!uid) return undefined;
const entry = await fsEntryStore.getEntryByUuid(uid);
if (!entry) return undefined;
const root = `/${username}/AppData/${appUid}`;
if (entry.path === root || entry.path.startsWith(`${root}/`)) {
return {};
}
return undefined;
},
});
// -- fs-access-levels exploder ----------------------------------
// `fs:UUID:see` implies `[list, read, write, manage:fs:UUID]`.
// ACLService.check already expands the same-family chain
@@ -2088,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,
@@ -2268,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) => ({
@@ -2795,10 +2923,13 @@ export class FSService extends PuterService {
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
if (existing) {
if (existing.isDir) {
// A directory already exists at path: idempotent success.
return existing;
}
if (input.overwrite) {
if (input.dedupeName) {
name = await this.#findDedupedName(parent, name);
} else {
// A directory already exists at path: idempotent success.
return existing;
}
} else if (input.overwrite) {
// Remove the non-directory occupant then create the dir.
await this.remove(userId, {
entry: existing,
@@ -3080,11 +3211,12 @@ export class FSService extends PuterService {
*/
async removeAllForUser(userId: number): Promise<void> {
const pageSize = 5000;
const falseLiteral = this.clients.db.booleanLiteral(false);
// Files-first loop: delete backing S3 objects in batches, then DB rows.
for (;;) {
const files = (await this.clients.db.read(
`SELECT uuid, bucket, bucket_region FROM fsentries
WHERE user_id = ? AND is_dir = 0 AND (is_shortcut = 0 OR is_shortcut IS NULL) AND (is_symlink = 0 OR is_symlink IS NULL)
WHERE user_id = ? AND is_dir = ${falseLiteral} AND (is_shortcut = ${falseLiteral} OR is_shortcut IS NULL) AND (is_symlink = ${falseLiteral} OR is_symlink IS NULL)
LIMIT ${pageSize}`,
[userId],
)) as Array<{
@@ -321,6 +321,115 @@ describe('MeteringService', () => {
});
});
// ── overuse alarm ────────────────────────────────────────────────
describe('overuse alarm', () => {
const wasOveruseAlarmed = (alarmSpy: ReturnType<typeof vi.spyOn>) =>
alarmSpy.mock.calls.some(
(call) =>
typeof call[0] === 'string' &&
call[0].includes('usage exceeded'),
);
it('does not alarm when a single large request crosses the limit in one shot', async () => {
const bigActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(bigActor);
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
// Previous usage was 0 (under the allowance) — one big request that
// blows straight past several multiples is legitimate, not abuse.
await target.incrementUsage(
bigActor,
'ai:chat',
1,
sub.monthUsageAllowance * 5,
);
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
alarmSpy.mockRestore();
});
it('does not alarm on further usage past the limit until the next multiple is crossed', async () => {
const overActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(overActor);
// Take them just over the allowance (into the 1x–2x band).
await target.incrementUsage(
overActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
// A small further expense stays within the same band — no new
// multiple crossed, so it shouldn't page.
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.incrementUsage(overActor, 'ai:chat', 1, 1_000);
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
alarmSpy.mockRestore();
});
it('alarms when a whole multiple of the allowance is crossed while already over', async () => {
const overActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(overActor);
// First expense takes them to the limit (1x) — no alarm yet.
await target.incrementUsage(
overActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
// Spy only on the expense that crosses into 2x while already over.
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.incrementUsage(
overActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
expect(alarmSpy).toHaveBeenCalledWith(
expect.stringContaining('usage exceeded'),
expect.stringContaining('exceeded their usage allowance'),
expect.objectContaining({ totalUsage: expect.any(Number) }),
// Non-paging severity — records and de-dupes but doesn't page on-call.
'warning',
);
alarmSpy.mockRestore();
});
it('does not alarm while purchased credits still cover the overage', async () => {
const creditActor: Actor = { user: makeUser() };
const sub = await target.getActorSubscription(creditActor);
await target.updateAddonCredit(
creditActor.user.uuid,
5_000_000_000,
);
// Cross to 2x — would page if not for the credits covering it.
await target.incrementUsage(
creditActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
const alarmSpy = vi.spyOn(server.clients.alarm, 'create');
await target.incrementUsage(
creditActor,
'ai:chat',
1,
sub.monthUsageAllowance,
);
expect(wasOveruseAlarmed(alarmSpy)).toBe(false);
alarmSpy.mockRestore();
});
});
// ── batchIncrementUsages ─────────────────────────────────────────
describe('batchIncrementUsages', () => {
@@ -184,7 +184,7 @@ export class MeteringService extends PuterService {
PERIOD_ESCAPE,
);
const appId = actor.app?.uid || GLOBAL_APP_KEY;
const userId = actor.user.uuid;
const userId = actor.user.uuid!;
const pathAndAmountMap = {
total: totalCost,
[`${escapedUsageType}.units`]: usageAmount,
@@ -360,7 +360,7 @@ export class MeteringService extends PuterService {
}
const appId = actor.app?.uid || GLOBAL_APP_KEY;
const userId = actor.user.uuid;
const userId = actor.user.uuid!;
const actorUsageKey = `${METRICS_PREFIX}:actor:${userId}:${currentMonth}`;
const actorUsagesPromise = this.stores.kv
@@ -893,20 +893,30 @@ export class MeteringService extends PuterService {
incrementCost,
} = ctx;
const allowedMultiple = Math.floor(
actorUsages.total / actorSubscription.monthUsageAllowance,
);
const previousMultiple = Math.floor(
(actorUsages.total - incrementCost) /
actorSubscription.monthUsageAllowance,
);
const isOver2x = allowedMultiple >= 2;
const crossedThreshold = previousMultiple < allowedMultiple;
const allowance = actorSubscription.monthUsageAllowance;
// No metered allowance to exceed (e.g. unlimited policies) — nothing to flag.
if (!(allowance > 0)) return;
const previousUsage = actorUsages.total - incrementCost;
const allowedMultiple = Math.floor(actorUsages.total / allowance);
const previousMultiple = Math.floor(previousUsage / allowance);
// Only alarm if the actor was ALREADY at or past their allowance before
// this expense arrived. A single large request that jumps past the limit
// in one shot (previous usage still under the allowance) is legitimate
// and shouldn't page.
const wasAlreadyOverLimit = previousUsage >= allowance;
// And only when this expense crosses into a new whole multiple of the
// allowance (2x, 3x, …) rather than on every expense once over — that
// first-over multiple is 2x, since being already over means the previous
// multiple was at least 1.
const crossedMultiple = previousMultiple < allowedMultiple;
const hasNoAddonCredit =
(actorAddons.purchasedCredits || 0) <=
(actorAddons.consumedPurchaseCredits || 0);
if (!(isOver2x && crossedThreshold && hasNoAddonCredit)) return;
if (!(wasAlreadyOverLimit && crossedMultiple && hasNoAddonCredit))
return;
this.clients.alarm.create(
`metering usage exceeded by user: ${actor.user?.username}`,
@@ -923,6 +933,8 @@ export class MeteringService extends PuterService {
totalUsage: actorUsages.total,
monthUsageAllowance: actorSubscription.monthUsageAllowance,
},
// Expected-but-worth-tracking signal — record/de-dupe it but don't page on-call.
'warning',
);
}
@@ -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).
+49 -109
View File
@@ -20,6 +20,7 @@
import { v4 as uuidv4 } from 'uuid';
import { PuterStore } from '../types';
import { HttpError } from '../../core/http/HttpError.js';
import { validateUrl } from '../../util/validation.js';
/**
* Persistence + cache for the `apps` table.
@@ -47,11 +48,11 @@ const OLD_APP_NAME_TTL_MONTHS = 3;
// backends without splitting the cap by driver.
const BULK_QUERY_CHUNK_SIZE = 200;
// Top-level all-time open/user counts: hot path, slow to compute, refreshed
// periodically by one instance and read via MGET on every app list/read.
// Top-level all-time open/user counts: hot path, slow to compute. Cached
// lazily on read (pipelined MGET on every app list/read; misses query the
// analytics source once and backfill) and left to autoexpire — these counts
// tolerate being slightly stale, so there's no background refresh.
const STATS_CACHE_TTL_SECONDS = 30 * 60;
const STATS_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const STATS_REFRESH_LOCK_KEY = 'appStatsLastRefresh';
// Period helpers for detailed/grouped stats. Ported from v1
// AppInformationService — queries go straight to ClickHouse/MySQL on demand
@@ -106,9 +107,18 @@ const READ_ONLY_COLUMNS = new Set([
'protected',
'is_private',
]);
const APP_BOOLEAN_COLUMNS = new Set([
'godmode',
'maximize_on_start',
'approved_for_listing',
'approved_for_opening_items',
'approved_for_incentive_program',
'background',
'protected',
'is_private',
]);
export class AppStore extends PuterStore {
#appStatsInterval;
// -- Reads --------------------------------------------------------
async getByUid(uid) {
@@ -341,7 +351,7 @@ export class AppStore extends PuterStore {
const colList = columns.map((c) => `\`${c}\``).join(', ');
const result = await this.clients.db.write(
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})`,
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})${this.clients.db.returningIdClause()}`,
values,
);
const insertId = result?.insertId;
@@ -365,6 +375,13 @@ export class AppStore extends PuterStore {
* `#isOriginBootstrapApp` checks.
*/
async createFromOrigin(uid, origin) {
// Bootstrap apps persist `origin` straight into `index_url`, which is
// later loaded as `iframe.src`. Enforce the same http(s) scheme
// allow-list as the AppDriver create/update path so a caller-supplied
// `javascript:`/`data:`/`file:` origin can never become a stored,
// launchable code-execution vector.
validateUrl(origin, { key: 'origin' });
const fields = {
name: uid,
title: uid,
@@ -380,7 +397,7 @@ export class AppStore extends PuterStore {
const colList = columns.map((c) => `\`${c}\``).join(', ');
const result = await this.clients.db.write(
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})`,
`INSERT INTO \`apps\` (${colList}) VALUES (${placeholders})${this.clients.db.returningIdClause()}`,
values,
);
const insertId = result?.insertId;
@@ -698,6 +715,7 @@ export class AppStore extends PuterStore {
async #resolveByOldName(name) {
const cutoffClause = this.clients.db.case({
sqlite: `datetime('now', '-${OLD_APP_NAME_TTL_MONTHS} months')`,
postgres: `(NOW() - INTERVAL '${OLD_APP_NAME_TTL_MONTHS} months')`,
otherwise: `(NOW() - INTERVAL ${OLD_APP_NAME_TTL_MONTHS} MONTH)`,
});
@@ -842,7 +860,10 @@ export class AppStore extends PuterStore {
const out = {};
for (const [k, v] of Object.entries(fields)) {
if (READ_ONLY_COLUMNS.has(k)) continue;
out[k] = v;
out[k] =
APP_BOOLEAN_COLUMNS.has(k) && v !== null && v !== undefined
? this.clients.db.booleanValue(Boolean(v))
: v;
}
return out;
}
@@ -976,7 +997,7 @@ export class AppStore extends PuterStore {
const period = options.period ?? 'all';
const grouping = options.grouping;
const timeRange = this.#computeTimeRange(period, options.createdAt);
const clickhouse = globalThis.clickhouseClient;
const clickhouse = this.clients.clickhouse;
if (grouping) {
if (!MYSQL_DATE_FORMATS[grouping]) {
@@ -1003,7 +1024,7 @@ export class AppStore extends PuterStore {
const out = new Map();
if (uids.length === 0) return out;
const clickhouse = globalThis.clickhouseClient;
const clickhouse = this.clients.clickhouse;
if (clickhouse) {
const res = await clickhouse.query({
query: `
@@ -1136,6 +1157,7 @@ export class AppStore extends PuterStore {
const periodExpr = this.clients.db.case({
mysql: `DATE_FORMAT(FROM_UNIXTIME(ts), '${timeFormat}')`,
sqlite: `STRFTIME('${timeFormat}', datetime(ts, 'unixepoch'))`,
postgres: `TO_CHAR(TO_TIMESTAMP(ts), '${this.#postgresDateFormat(grouping)}')`,
otherwise: `DATE_FORMAT(FROM_UNIXTIME(ts), '${timeFormat}')`,
});
const rows = await this.clients.db.read(
@@ -1156,6 +1178,23 @@ export class AppStore extends PuterStore {
return this.#assembleGroupedResult(processed, allPeriods, grouping);
}
#postgresDateFormat(grouping) {
switch (grouping) {
case 'hour':
return 'YYYY-MM-DD HH24:00:00';
case 'day':
return 'YYYY-MM-DD';
case 'week':
return 'YYYY-WW';
case 'month':
return 'YYYY-MM';
case 'year':
return 'YYYY';
default:
return 'YYYY-MM-DD';
}
}
#assembleGroupedResult(rows, allPeriods, grouping) {
// Totals come from raw rows so they survive even if a row's
// period key fails to match an `allPeriods` entry (e.g. week
@@ -1334,103 +1373,4 @@ export class AppStore extends PuterStore {
}
return 1 + Math.ceil((firstThursday - target) / 604800000);
}
// -- Refresh loop -------------------------------------------------
async #refreshAppStats() {
// Cross-instance lock: if another node refreshed within the window,
// skip. TTL slightly longer than the interval so the guard survives
// jitter in the setInterval drift.
try {
const last = parseInt(
(await this.clients.redis.get(STATS_REFRESH_LOCK_KEY)) || '0',
10,
);
const now = Date.now();
if (now - last < STATS_REFRESH_INTERVAL_MS) return;
await this.clients.redis.set(
STATS_REFRESH_LOCK_KEY,
String(now),
'EX',
Math.floor(STATS_REFRESH_INTERVAL_MS / 1000) + 60,
);
} catch {
// Keep going — better to over-refresh than miss updates entirely.
}
const clickhouse = globalThis.clickhouseClient;
let rows;
try {
if (clickhouse) {
const res = await clickhouse.query({
query: `
SELECT app_uid,
count(_id) AS open_count,
count(DISTINCT user_id) AS user_count
FROM app_opens
GROUP BY app_uid
`,
format: 'JSONEachRow',
});
rows = await res.json();
} else {
rows = await this.clients.db.read(
`SELECT app_uid,
COUNT(_id) AS open_count,
COUNT(DISTINCT user_id) AS user_count
FROM app_opens
GROUP BY app_uid`,
);
}
} catch (e) {
console.warn('[AppStore] refresh app stats failed:', e);
return;
}
if (!rows?.length) return;
try {
const pipeline = this.clients.redis.pipeline();
for (const row of rows) {
if (!row.app_uid) continue;
pipeline.set(
this.#openCountCacheKey(row.app_uid),
String(parseInt(row.open_count, 10) || 0),
'EX',
STATS_CACHE_TTL_SECONDS,
);
pipeline.set(
this.#userCountCacheKey(row.app_uid),
String(parseInt(row.user_count, 10) || 0),
'EX',
STATS_CACHE_TTL_SECONDS,
);
}
await pipeline.exec();
} catch (e) {
console.warn('[AppStore] refresh app stats cache write failed:', e);
}
}
onServerStart() {
// Kick off one refresh immediately so the cache is warm before the
// first client request (failing silently is fine — getAppsStats
// falls back to an on-demand query).
this.#refreshAppStats().catch(() => {});
// Jitter prevents every node refreshing on the same tick when a
// cluster boots together.
this.#appStatsInterval = setInterval(
() => {
this.#refreshAppStats().catch(() => {});
},
STATS_REFRESH_INTERVAL_MS + Math.floor(Math.random() * 500),
);
}
onServerShutdown() {
if (this.#appStatsInterval) {
clearInterval(this.#appStatsInterval);
this.#appStatsInterval = undefined;
}
}
}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more