mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40d1c998bb | ||
|
|
b4837d49b6 | ||
|
|
e23042b42e | ||
|
|
240a733285 | ||
|
|
bb202be12c | ||
|
|
8c4cb601c9 | ||
|
|
64a7c7b7c4 | ||
|
|
2dd2887ff7 | ||
|
|
4bbe8a2789 | ||
|
|
3e9ceb673b | ||
|
|
9c4d1ef535 | ||
|
|
bf57518262 | ||
|
|
d48fb11de6 | ||
|
|
7ed87f760b | ||
|
|
ee37be16f0 | ||
|
|
a8cfaeefbd | ||
|
|
58cb8ee048 | ||
|
|
a72fbd1d47 | ||
|
|
f6408db74e | ||
|
|
df20993d8b | ||
|
|
708fe1bc4e | ||
|
|
e782fa832e | ||
|
|
c947c2c989 | ||
|
|
3de4613b7d | ||
|
|
def7290c48 | ||
|
|
1335b46404 | ||
|
|
0e15ac98a9 | ||
|
|
f127b2509d | ||
|
|
15134c99bf | ||
|
|
8262e3193e | ||
|
|
98a21c4ac0 | ||
|
|
c52eb1b903 | ||
|
|
d6a71e50bb | ||
|
|
a4b6ee0dd2 | ||
|
|
bb61103080 | ||
|
|
9bbc76b2dd | ||
|
|
918aba9c80 | ||
|
|
3ceba9e3b3 | ||
|
|
a480679a2d | ||
|
|
e99ae40e93 | ||
|
|
3cd50fbaf3 | ||
|
|
60dd1453d4 | ||
|
|
07781cd681 | ||
|
|
2387d8dcc9 | ||
|
|
723608f4db | ||
|
|
bd1dcde7b0 | ||
|
|
5b8e41e66d | ||
|
|
acc4796b99 | ||
|
|
106e85d6ca | ||
|
|
0ffc32f19e | ||
|
|
d897cc5dd0 | ||
|
|
6d2f277ce2 | ||
|
|
86f80b29c0 | ||
|
|
39243735e8 | ||
|
|
b3ff9d74b7 | ||
|
|
7b7604440f | ||
|
|
382d2b3c14 | ||
|
|
eda5379c17 | ||
|
|
d2fee51844 | ||
|
|
8ac4bad196 | ||
|
|
f140d7221c | ||
|
|
631f0bda1b | ||
|
|
1121314582 |
No files matched your search
@@ -3,6 +3,8 @@ name: Backend Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths-ignore:
|
||||
- 'src/docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
@@ -70,3 +70,5 @@ coverage/
|
||||
undefined
|
||||
servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
+34
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 ?`,
|
||||
|
||||
@@ -71,6 +71,6 @@ export const handleInstalledApps = async (
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true },
|
||||
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
|
||||
handleInstalledApps,
|
||||
);
|
||||
Generated
+1834
-2603
File diff suppressed because it is too large.
Load diff
@@ -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>;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
Reference in new issue
Block a user