mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7ff47442 | ||
|
|
148771a82b | ||
|
|
5bad6a972f | ||
|
|
8bb7d59527 | ||
|
|
8732494442 | ||
|
|
cade75ac9b | ||
|
|
9abc9d1a19 | ||
|
|
f3fd8a30da | ||
|
|
b2324cd212 | ||
|
|
a1c8ba4cdd | ||
|
|
30461e7d95 | ||
|
|
46253fb80a | ||
|
|
29f2a4d9c9 | ||
|
|
e00e1e57da | ||
|
|
6668d778d2 | ||
|
|
7f3691aea3 | ||
|
|
1e280601c6 | ||
|
|
2262975785 | ||
|
|
e6e6e3ba9a | ||
|
|
e85cd9d53d | ||
|
|
828b5d9430 | ||
|
|
3b1ba72ddd | ||
|
|
4245ac04ac | ||
|
|
b8559c1221 | ||
|
|
6c4fa629a9 | ||
|
|
e6531d4357 | ||
|
|
0c27571030 | ||
|
|
6e6d9d2869 | ||
|
|
34529b2e79 | ||
|
|
7e4ac6542e | ||
|
|
3331dbd464 | ||
|
|
391b175a68 | ||
|
|
19f35b4200 | ||
|
|
9877bb79d0 | ||
|
|
2982e894b1 | ||
|
|
10bdf701e0 | ||
|
|
e764a10ee4 | ||
|
|
7e461e14a2 | ||
|
|
48cc706ad3 | ||
|
|
20ea616b59 | ||
|
|
73a7a38ea8 | ||
|
|
5c3fab5579 | ||
|
|
695862c61f | ||
|
|
bbc86e7b70 | ||
|
|
e35b57068b | ||
|
|
5faed55076 | ||
|
|
928d5fec16 | ||
|
|
ebcc4bb597 | ||
|
|
51541d3739 | ||
|
|
43b9200205 | ||
|
|
d5e9417d2d | ||
|
|
dec205d959 | ||
|
|
d87f3464fc | ||
|
|
25410cac92 | ||
|
|
66a1baef24 | ||
|
|
5c4df9cbd7 | ||
|
|
f89b3a888a | ||
|
|
bf545dda25 | ||
|
|
c706b4472d | ||
|
|
93b8041c3d | ||
|
|
89f9f9728f | ||
|
|
645409eddb | ||
|
|
58a763bcd1 | ||
|
|
fd91d72654 | ||
|
|
7fbf491e6f | ||
|
|
a8833de9d5 | ||
|
|
3a8b6394de | ||
|
|
f5d656650f | ||
|
|
dd314da16d | ||
|
|
47c1d4268f | ||
|
|
b9e87935cc | ||
|
|
73dd95bd41 | ||
|
|
c5bc358ef2 | ||
|
|
57c9eb9e1a | ||
|
|
a0c0544928 | ||
|
|
6dcae81a56 | ||
|
|
09baee6381 | ||
|
|
68aab718a1 | ||
|
|
5d6b2f3265 | ||
|
|
28730010c3 | ||
|
|
817ece40ba | ||
|
|
7c868e947a | ||
|
|
66984cd3d6 | ||
|
|
5fa508ba91 | ||
|
|
c1fca467bf | ||
|
|
e726c19311 | ||
|
|
6643642aa7 | ||
|
|
65dc3b5f4c | ||
|
|
c225fabbd4 | ||
|
|
fdcc2e301b | ||
|
|
183ab0780d | ||
|
|
81c69f243b | ||
|
|
6a3ecaff2a | ||
|
|
70f343d6fe | ||
|
|
172aa01e1c | ||
|
|
cba6ac8184 | ||
|
|
492f3d235a | ||
|
|
d9a01a3748 | ||
|
|
aab21ef91d | ||
|
|
2479e6a064 | ||
|
|
7cba26bb39 | ||
|
|
af0dc06f49 | ||
|
|
14900514b9 | ||
|
|
7faaef8cf0 | ||
|
|
c188257f85 | ||
|
|
4543a645f2 | ||
|
|
75d3b07e0f | ||
|
|
9da32c73cb | ||
|
|
102cef523f | ||
|
|
0e1be72f92 | ||
|
|
ef1afb9cc1 | ||
|
|
0462ddd6f5 | ||
|
|
8d7e70cde4 | ||
|
|
52e481128f | ||
|
|
c237e2433f |
No files matched your search
@@ -1,10 +1,19 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Runs on backend and extension changes. Tests are co-located with the
|
||||
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
|
||||
# changes match the same globs. puter.js SDK changes are covered by
|
||||
# puterjs-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths-ignore:
|
||||
- 'src/docs/**'
|
||||
paths:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Puter.js API Tests
|
||||
|
||||
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
|
||||
# so test changes are covered by the same glob — plus the pieces the
|
||||
# runners depend on: the worker preamble, the in-memory test env in
|
||||
# testUtil, and the backend vitest config the API-tests config extends.
|
||||
# Backend and extension changes run backend-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
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
|
||||
|
||||
# The runners exercise the built artifacts: the SDK bundle
|
||||
# (src/puter-js/dist) for node + browser, and the worker preamble
|
||||
# (src/worker/dist) for workerd.
|
||||
- name: Build puter.js SDK and worker preamble
|
||||
run: npm run build:workerLib
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run puter.js API tests (node, browser, workerd)
|
||||
run: npm run test:puterjs
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
# SDK coverage on both refs, reported as a PR comment — same shape as
|
||||
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
|
||||
# (the `test` job above keeps exercising the production build).
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The base leg is best-effort — it only enriches the PR comment with
|
||||
# a comparison, and the base ref may predate the coverage script.
|
||||
- name: Run puter.js API tests with coverage
|
||||
continue-on-error: ${{ matrix.artifact == 'base' }}
|
||||
run: npm run test:puterjs:coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
report-coverage:
|
||||
needs: coverage
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
|
||||
# May not exist (base ref without the coverage script, or a failed
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-jsdoc"]
|
||||
}
|
||||
@@ -8,6 +8,8 @@ Use these as the source of truth before exploring further:
|
||||
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
|
||||
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
|
||||
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
@@ -16,72 +18,21 @@ Use these as the source of truth before exploring further:
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
## Repo-wide conventions
|
||||
|
||||
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
|
||||
These apply everywhere — backend, puter.js, and GUI.
|
||||
|
||||
### Layers (top → bottom)
|
||||
### Language & files
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
|
||||
|
||||
### Cross-layer rules
|
||||
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
|
||||
|
||||
### Language & file conventions
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- Write ES modules, not CommonJS — we transpile and build as needed.
|
||||
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them. In puter.js, type with JSDoc instead (see the puter.js section for what must be typed).
|
||||
- **Reuse before adding.** Search for an existing type, helper, or implementation and extend it; only add a new one when nothing suitable exists.
|
||||
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places belongs in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)) rather than bloating a logic file; a type with a single consumer can stay next to it.
|
||||
- Naming: `camelCase` for variables/functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code** — new local variables, parameters, functions, and internal object properties. `snake_case` is reserved for identifiers that are part of an external contract: wire/JSON keys sent to or received from the backend, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), but don't introduce new `snake_case` alongside them either — a camelCase local can carry a value into a `snake_case` wire key (`{ operation_id: operationId }`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
|
||||
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
|
||||
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
|
||||
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
|
||||
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
|
||||
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
|
||||
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
|
||||
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
|
||||
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
@@ -96,6 +47,106 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
### Working rules of thumb
|
||||
|
||||
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
| Layer (top → bottom) | Lives in | Responsibility |
|
||||
| -------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers: parse/validate input, per-route gates via `RouteOptions` (auth, subdomain, rate limit, body parsers), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional RPC-style handlers on `/drivers/*`; thin shells that validate inputs and call services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence; wraps clients in the domain shapes services consume. |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, email, event bus). Protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
A public API can be exposed through either a controller or a driver — both are supported; prefer a controller when you need fine-grained control over routes and gates. [doc/contributing-apis.md](doc/contributing-apis.md) has the decision guide with links to the decorators and middleware for each.
|
||||
|
||||
Cross-layer rules:
|
||||
|
||||
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** Two services needing the same logic means a util/helper, not a service-to-service dependency.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only for genuinely request-scoped values that would otherwise thread through many layers — today mostly `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
[extensions/](extensions/) parallels the layered stack and is for **non-crucial parts of the system** — Puter still works with the extension removed. If core needs to call it, it belongs in core, not an extension (see [whoami](extensions/whoami.ts) as the cautionary example). The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` — lazy proxy to instantiated core objects. `extension.config` exposes live config.
|
||||
|
||||
Follow the same layered structure inside an extension — unless it only needs a few route handlers, in which case the lightweight helpers are enough on their own.
|
||||
|
||||
### Backend tests
|
||||
|
||||
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
|
||||
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
|
||||
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
|
||||
|
||||
---
|
||||
|
||||
## puter.js (the SDK)
|
||||
|
||||
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
|
||||
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `KV.js`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), hand-maintained type declarations in [src/puter-js/types/](src/puter-js/types/), API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
|
||||
|
||||
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. Reference the hand-maintained declarations in [src/puter-js/types/](src/puter-js/types/) with `import(...)` types rather than re-declaring shapes:
|
||||
|
||||
```js
|
||||
/** @typedef {import('../../types/modules/ai').ChatOptions} ChatOptions */
|
||||
|
||||
/** @type {ChatOptions} */
|
||||
const options = { model: 'gpt-5-nano' };
|
||||
```
|
||||
|
||||
When a shape only exists locally, declare it as an inline object literal — not the `@typedef {Object}` + `@property` list form — and use `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string, value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value, matching the `.d.ts` declarations exactly. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean.
|
||||
|
||||
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @typedef {{key:string, value: unknown}} KVEntry
|
||||
*/
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
```
|
||||
|
||||
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
|
||||
|
||||
1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
|
||||
2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
|
||||
3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
|
||||
4. **Types.** Update [src/puter-js/types/modules/](src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts` / `types/puter.d.ts`. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
|
||||
5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
|
||||
|
||||
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
|
||||
|
||||
---
|
||||
|
||||
## GUI
|
||||
|
||||
[src/gui/](src/gui/) is the Puter desktop: deliberately plain JavaScript + jQuery with HTML-string templates. Don't introduce a UI framework or a new rendering pattern.
|
||||
|
||||
The guiding rule here is **conformity over novelty** — match the existing design and code structure even where you'd personally choose differently. A visually or structurally divergent addition is a defect even when it works.
|
||||
|
||||
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
|
||||
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
|
||||
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
|
||||
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
|
||||
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
|
||||
+4
-2
@@ -16,6 +16,8 @@ Add tests for new behavior, endpoints, or bug fixes. If something's genuinely ha
|
||||
|
||||
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
|
||||
|
||||
In plain-JS files, typing is encouraged via JSDoc `@type` annotations using the TypeScript type system, with `@typedef` for shared shapes. Don't type API surfaces as `unknown` or untyped `...args` unless the values are passed through transparently to an upstream layer that owns their type.
|
||||
|
||||
## 3. Don't expose system or user information
|
||||
|
||||
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
|
||||
@@ -26,9 +28,9 @@ For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
|
||||
|
||||
## 5. Update docs for API changes
|
||||
## 5. Adding or changing APIs
|
||||
|
||||
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
|
||||
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||
|
||||
@@ -287,6 +287,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"infron": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://llm.onerouter.pro/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
@@ -325,6 +329,7 @@
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"speechify": { "apiKey": "" },
|
||||
"aws-textract": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Contributing APIs
|
||||
|
||||
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, `/drivers/*` methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — [AGENTS.md](../AGENTS.md) defers to it for API work.
|
||||
|
||||
Companion docs: [architecture.md](architecture.md) (backend layering), [pagination.md](pagination.md) (list APIs), [src/puter-js/tests/api/README.md](../src/puter-js/tests/api/README.md) (SDK test environment).
|
||||
|
||||
## Rule zero: don't break callers
|
||||
|
||||
puter.js is served live from `https://js.puter.com/v2/` with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
|
||||
|
||||
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
|
||||
|
||||
## Core or extension?
|
||||
|
||||
The first decision is where the API lives.
|
||||
|
||||
If the API is **not crucial to core functionality — nothing in core will call it — prefer an extension** over wiring it into core. Extensions live in [extensions/](../extensions/), parallel the core layered stack, and reach core through `extension.import(...)`:
|
||||
|
||||
```js
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
extension.registerDriver('myFeature', MyFeatureDriver); // first-class driver
|
||||
extension.post('/my-feature/frobnicate', opts, handler); // or plain routes
|
||||
```
|
||||
|
||||
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight `extension.get/post/...` helpers are enough on their own.
|
||||
|
||||
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see [whoami](../extensions/whoami.ts) for the cautionary example of a load-bearing "extension".
|
||||
|
||||
## Adding a new API
|
||||
|
||||
Work through all seven steps; the PR is complete when every one is.
|
||||
|
||||
### 1. Design the surface first
|
||||
|
||||
- Sketch the signature, options, return shape, and error cases before writing code. Find the two or three most similar existing APIs and match their conventions.
|
||||
- New parameter and field names are `camelCase`. (Existing `snake_case` names stay where they already exist.)
|
||||
- Anything returning a list follows the [pagination convention](pagination.md): `limit`/`cursor` in, `{ items, cursor, total }` envelope out.
|
||||
- Prefer an options object over a growing list of positional parameters, but keep the common case callable with a single argument where siblings do.
|
||||
|
||||
### 2. Backend
|
||||
|
||||
Follow the layered stack ([architecture.md](architecture.md)): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
|
||||
|
||||
#### Controller or driver?
|
||||
|
||||
Both are supported ways to define an API. **Prefer a controller when you need fine-grained control** — URL shape, HTTP verbs, per-route gates, response and streaming formats. A **driver** fits when the API is a set of RPC methods implementing one of the named interfaces on `/drivers/*` (`puter-kvstore`, `puter-chat-completion`, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
|
||||
|
||||
- **Controller:** extend `PuterController` ([src/backend/controllers/types.ts](../src/backend/controllers/types.ts)) and declare routes with the `@Controller(prefix)` class decorator plus `@Get`/`@Post`/… method decorators ([src/backend/core/http/decorators.ts](../src/backend/core/http/decorators.ts)), each taking `(path, routeOptions)` — or override `registerRoutes(router)` imperatively. Core controllers register in [src/backend/controllers/index.ts](../src/backend/controllers/index.ts); extensions use `extension.registerController(...)` or the plain route helpers.
|
||||
- **Driver:** extend `PuterDriver` ([src/backend/drivers/types.ts](../src/backend/drivers/types.ts)) and mark it with the `@Driver(interfaceName, opts)` decorator ([src/backend/drivers/decorators.ts](../src/backend/drivers/decorators.ts)), which also declares its policies. Core drivers register in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts); extensions use `extension.registerDriver(...)`.
|
||||
|
||||
#### Middleware and gates
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
|
||||
- Validate cheap preconditions client-side and throw `{ message, code }` objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.
|
||||
|
||||
### 4. Types
|
||||
|
||||
- Add or extend the declaration in [src/puter-js/types/modules/](../src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts`. Declarations must match the runtime exactly — optionality, defaults, and return types included.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- Add the method page at [src/docs/src/](../src/docs/src/)`<Area>/<method>.md` — frontmatter (`title`, `description`, `platforms`), syntax, parameters, return value, and at least one runnable example — and update the area overview (`<Area>.md`). Copy the structure of an existing page.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- **Backend:** colocated Vitest tests; prefer the in-memory test server (`setupPuterTestEnv` in [src/backend/testUtil.ts](../src/backend/testUtil.ts)) over mocking.
|
||||
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`). One suite runs on node, browser, and workerd via `npm run test:puterjs` — never write per-platform tests, and rebuild first with `npm run build:workerLib` since the runners execute the built bundle.
|
||||
- **Desktop-rendered UI** (`puter.ui.*`): add a Playwright spec per [src/puter-js/TESTING.md](../src/puter-js/TESTING.md).
|
||||
|
||||
### 7. Security pass
|
||||
|
||||
Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
|
||||
|
||||
## Maintaining an existing API
|
||||
|
||||
Changes are **additive by default**:
|
||||
|
||||
- New parameters are optional, with defaults that reproduce the old behavior exactly.
|
||||
- Never rename, repurpose, or remove existing parameters, response fields, or error codes. Don't change types, ordering guarantees, or which fields are present when.
|
||||
- New behavior that could surprise existing callers goes behind an opt-in flag.
|
||||
- Docs, types, and tests move in the same PR as the behavior. A signature change with stale docs is a bug — the docs are the contract users code against.
|
||||
- Bug fixes come with a regression test that fails before the fix. Be suspicious of fixes that change observable behavior: someone may depend on the bug. When in doubt, ask a maintainer.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
|
||||
|
||||
### Deprecating
|
||||
|
||||
The old surface keeps working. Mark it `@deprecated` in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] Backward compatible (or the break was explicitly approved)
|
||||
- [ ] Right home: extension if core never calls it; layered structure either way
|
||||
- [ ] puter.js method matches sibling conventions; errors are `{ message, code }` with stable codes
|
||||
- [ ] Types updated and matching runtime behavior
|
||||
- [ ] Docs page + area overview updated, with a runnable example
|
||||
- [ ] Tests: backend + three-platform SDK suite (+ e2e for desktop-rendered UI)
|
||||
- [ ] Security pass on the diff
|
||||
- [ ] You ran it end to end
|
||||
@@ -0,0 +1,100 @@
|
||||
# Pagination convention for list APIs
|
||||
|
||||
Every list endpoint follows one wire contract. This document is the source of
|
||||
truth for adding pagination to a new or existing list surface.
|
||||
|
||||
## Wire contract
|
||||
|
||||
Requests accept:
|
||||
|
||||
| Param | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `limit` | number | Maximum items per page. Each endpoint documents its cap and default. |
|
||||
| `cursor` | string \| null | Opaque continuation token. `null` (or any presence of the key) requests the first page. |
|
||||
| `offset` | number | Skip N items. Legacy/discouraged — see below. Cannot be combined with `cursor`. |
|
||||
| `includeTotal` | boolean | Adds `total` to the response. |
|
||||
|
||||
Paginated responses are an envelope:
|
||||
|
||||
```json
|
||||
{ "items": [...], "cursor": "…", "total": 123 }
|
||||
```
|
||||
|
||||
- `cursor` is present only while more pages exist. Clients iterate until it
|
||||
is absent.
|
||||
- `total` is present only when the request set `includeTotal`.
|
||||
- **Pages may be short.** Post-query filtering (TTL expiry, permission
|
||||
checks) can shrink a page below `limit` — or even to zero — while a
|
||||
`cursor` is still returned. Never use `items.length < limit` as an
|
||||
end-of-list signal.
|
||||
|
||||
New request params are camelCase (`includeTotal`, `fetchUntilFull`).
|
||||
Pre-existing snake_case params stay for compatibility.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Requests without pagination params keep returning the full result in the
|
||||
legacy shape (bare array) forever — old clients never break.
|
||||
|
||||
The envelope trigger depends on the endpoint's history:
|
||||
|
||||
- Endpoints where `limit` pre-dates the convention (readdir, subdomain
|
||||
`select`) return the envelope only when the request contains `cursor`
|
||||
(including `null`) or `includeTotal`; `limit`/`offset`-only requests keep
|
||||
the bare array.
|
||||
- Endpoints where every pagination param is new (kv `list`, workers
|
||||
`getFilePaths`) return the envelope when any pagination param is present.
|
||||
|
||||
## Cursors
|
||||
|
||||
Cursors are opaque base64-encoded JSON, produced and consumed only by the
|
||||
backend (`src/backend/util/pagination.ts`). What a cursor wraps is an
|
||||
implementation detail per store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
ending in a unique tiebreaker (`id`).
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
## Offset
|
||||
|
||||
SQL-backed endpoints support `offset` natively. DynamoDB-backed endpoints
|
||||
(kv) emulate it by advancing past skipped items with `Select: COUNT` queries
|
||||
— no item data is transferred, but read capacity is still consumed for
|
||||
everything skipped, and the caller is metered for it. **Offset is supported
|
||||
for parity, not recommended** — cost grows linearly with the offset, so it is
|
||||
capped (kv: 5000). Use cursors.
|
||||
|
||||
## Totals (`includeTotal`)
|
||||
|
||||
- SQL: `SELECT COUNT(*)` with the same WHERE clause as the listing.
|
||||
- DynamoDB: a `Select: COUNT` loop over the query (TTL-filtered), metered to
|
||||
the caller. Cost is proportional to the total item count — request the
|
||||
total on the first page only, not every page.
|
||||
- Where visibility is decided per-actor after the query (protected apps in
|
||||
the catalog listing), `total` approximates the visible set: it counts
|
||||
non-protected plus caller-owned rows, and misses rows visible only through
|
||||
explicit permission grants.
|
||||
|
||||
## fetchUntilFull (kv only)
|
||||
|
||||
DynamoDB applies `Limit` before its filter expression, so TTL-filtered pages
|
||||
are structurally short. `fetchUntilFull: true` makes the backend keep
|
||||
fetching (bounded number of continuation queries) until the page holds
|
||||
`limit` items or the keyset is exhausted. Requires `limit`. If the bound is
|
||||
hit, the response simply carries a cursor — still convention-legal.
|
||||
|
||||
## Adding pagination to a new endpoint
|
||||
|
||||
1. Use `encodeCursor`/`decodeCursor`/`normalizeLimit`/`normalizeOffset` from
|
||||
`src/backend/util/pagination.ts`.
|
||||
2. Push equality filters into the query so pages and counts operate on the
|
||||
true result set; only genuinely per-actor filtering may remain post-query
|
||||
(short-pages rule covers it).
|
||||
3. Fetch `limit + 1` rows to detect whether a next page exists (SQL), or use
|
||||
`LastEvaluatedKey` (DynamoDB).
|
||||
4. Keep the no-params request returning the legacy full result.
|
||||
@@ -28,7 +28,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedOwnedApp = async (prefix: string) => {
|
||||
@@ -50,6 +50,32 @@ const seedOwnedApp = async (prefix: string) => {
|
||||
return { owner, app: app! };
|
||||
};
|
||||
|
||||
const seedUser = async (prefix: string, email: string | null = null) =>
|
||||
server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email,
|
||||
});
|
||||
|
||||
const grantAuthenticated = async (appId: number, userId: number) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, 'flag:app-is-authenticated', null],
|
||||
);
|
||||
};
|
||||
|
||||
const grantEmailRead = async (
|
||||
appId: number,
|
||||
userId: number,
|
||||
userUuid: string,
|
||||
) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, `user:${userUuid}:email:read`, null],
|
||||
);
|
||||
};
|
||||
|
||||
describe('appTelemetry driver — get_users', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
await expect(driver.get_users({})).rejects.toMatchObject({
|
||||
@@ -117,6 +143,66 @@ describe('appTelemetry driver — get_users', () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits user_email for a user who did not grant email:read', async () => {
|
||||
const { owner, app } = await seedOwnedApp('noemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user).toBe(member.username);
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
// No grant → the field must be absent (not just null), so the email
|
||||
// never leaks to the app owner.
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns user_email when the user granted email:read to the app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('withemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
await grantEmailRead(
|
||||
app.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
expect(row.user_email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not leak email granted to a *different* app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('appA');
|
||||
const { app: otherApp } = await seedOwnedApp('appB');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
// Grant email:read against the OTHER app only.
|
||||
await grantEmailRead(
|
||||
otherApp.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
|
||||
@@ -70,7 +70,9 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
|
||||
} = {}): Promise<
|
||||
Array<{ user: string; user_uuid: string; user_email?: string | null }>
|
||||
> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const safeLimit = parseIntParam(limit, {
|
||||
@@ -105,16 +107,45 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const appId = (app as { id: number }).id;
|
||||
|
||||
const users = (await this.clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
`SELECT u.id, u.username, u.uuid, u.email FROM user_to_app_permissions p
|
||||
INNER JOIN ${this.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 ?`,
|
||||
[(app as { id: number }).id, safeLimit, safeOffset],
|
||||
)) as Array<{ username: string; uuid: string }>;
|
||||
[appId, safeLimit, safeOffset],
|
||||
)) as Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
uuid: string;
|
||||
email: string | null;
|
||||
}>;
|
||||
|
||||
return users.map((e) => ({ user: e.username, user_uuid: e.uuid }));
|
||||
// Only surface a user's email if *that user* granted this app the
|
||||
// `user:<their-uuid>:email:read` permission — the same grant
|
||||
// `puter.perms.requestEmail()` obtains and `whoami` honours. This is a
|
||||
// per-user check keyed on the app (not the calling owner-actor): a
|
||||
// user may have authenticated into the app without sharing their
|
||||
// email. Resolve the whole page in one query.
|
||||
const emailPermitted = new Set<number>();
|
||||
if (users.length > 0) {
|
||||
const permStrings = users.map((u) => `user:${u.uuid}:email:read`);
|
||||
const placeholders = permStrings.map(() => '?').join(', ');
|
||||
const grants = (await this.clients.db.read(
|
||||
`SELECT user_id FROM user_to_app_permissions
|
||||
WHERE app_id = ? AND permission IN (${placeholders})`,
|
||||
[appId, ...permStrings],
|
||||
)) as Array<{ user_id: number }>;
|
||||
for (const g of grants) emailPermitted.add(g.user_id);
|
||||
}
|
||||
|
||||
return users.map((e) =>
|
||||
emailPermitted.has(e.id)
|
||||
? { user: e.username, user_uuid: e.uuid, user_email: e.email }
|
||||
: { user: e.username, user_uuid: e.uuid },
|
||||
);
|
||||
}
|
||||
|
||||
/** Count of users who have authenticated into the given app. */
|
||||
|
||||
@@ -38,7 +38,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
|
||||
@@ -48,7 +48,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
|
||||
@@ -108,7 +108,7 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('returns early when event has no items array', async () => {
|
||||
@@ -188,7 +188,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
|
||||
@@ -281,7 +281,7 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
|
||||
Generated
+1324
-74
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "puter.com",
|
||||
"version": "2.5.1",
|
||||
"version": "26.07",
|
||||
"author": "Puter Technologies Inc.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Desktop environment in the browser!",
|
||||
@@ -10,6 +10,7 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
@@ -20,6 +21,8 @@
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-istanbul": "^8.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -31,9 +34,13 @@
|
||||
"globals": "^15.15.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"license-check-and-add": "^4.0.5",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-jsdoc": "^1.8.1",
|
||||
"simple-git": "^3.32.3",
|
||||
"typescript": "^5.4.5",
|
||||
"uglify-js": "^3.17.4",
|
||||
@@ -46,6 +53,12 @@
|
||||
"scripts": {
|
||||
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:puterjs": "npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
|
||||
"test:puterjs:node": "npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
|
||||
"test:puterjs:browser": "npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
|
||||
"test:puterjs:workerd": "npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
|
||||
"test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
|
||||
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
|
||||
"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 setupExtensions && npm run build:ts",
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMigrationFilenames } from './MySQLDatabaseClient.js';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import {
|
||||
MySQLDatabaseClient,
|
||||
compareMigrationFilenames,
|
||||
} from './MySQLDatabaseClient.js';
|
||||
|
||||
describe('compareMigrationFilenames', () => {
|
||||
it('orders numbered migrations numerically, not lexically', () => {
|
||||
@@ -93,3 +97,76 @@ describe('compareMigrationFilenames', () => {
|
||||
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Replica read failover ───────────────────────────────────────────
|
||||
//
|
||||
// `read()` normally goes to the replica batcher; when the replica side is
|
||||
// degraded (batcher load-shed or a transient connection error) and a real
|
||||
// replica is configured, the read retries once on the primary batcher.
|
||||
|
||||
type Batcher = { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
const makeClient = (opts: {
|
||||
replica: Batcher;
|
||||
primary: Batcher;
|
||||
multiNode?: boolean;
|
||||
}) => {
|
||||
const client = new MySQLDatabaseClient({
|
||||
database: { engine: 'mysql' },
|
||||
} as IConfig);
|
||||
// Bypass onServerStart (which would connect to a real database) and
|
||||
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
|
||||
Object.assign(client as unknown as Record<string, unknown>, {
|
||||
dbReplica: opts.replica,
|
||||
db: opts.primary,
|
||||
configuration: opts.multiNode === false ? 0 : 1,
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
const codedError = (code: string) => {
|
||||
const err = new Error(code) as Error & { code: string };
|
||||
err.code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
describe('MySQLDatabaseClient.read — replica failover', () => {
|
||||
it('fails over to the primary on batcher load-shed errors', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')) };
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
expect(primary.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails over on transient connection errors', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('ECONNRESET')) };
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
});
|
||||
|
||||
it('rethrows deterministic SQL errors without touching the primary', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('ER_PARSE_ERROR')) };
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELEC oops')).rejects.toMatchObject({
|
||||
code: 'ER_PARSE_ERROR',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail over in single-node configuration', async () => {
|
||||
const replica = { execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')) };
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary, multiNode: false });
|
||||
|
||||
await expect(client.read('SELECT 1')).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,32 +19,24 @@
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { isRetriableError } from './retriableErrors.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
const replicaFailoverCounter = metrics
|
||||
.getMeter('puter-backend')
|
||||
.createCounter('db.read.replica_failover', {
|
||||
description:
|
||||
'Reads that failed on the replica batcher and were retried on the primary',
|
||||
});
|
||||
|
||||
export { compareMigrationFilenames };
|
||||
|
||||
@@ -86,7 +78,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -97,7 +89,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
await this.runMigrations();
|
||||
}
|
||||
@@ -144,7 +136,23 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.dbReplica.execute(query, params);
|
||||
let result;
|
||||
try {
|
||||
result = await this.dbReplica.execute(query, params);
|
||||
} catch (error) {
|
||||
// Replica-side degradation (batcher load-shed or a transient
|
||||
// connection failure) shouldn't fail reads while the primary is
|
||||
// healthy. Deterministic errors (bad SQL) are rethrown — they
|
||||
// would fail identically on the primary.
|
||||
if (
|
||||
this.configuration !== Configuration.REPLICA ||
|
||||
!MySQLDatabaseClient.isFailoverWorthy(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
replicaFailoverCounter.add(1);
|
||||
result = await this.db.execute(query, params);
|
||||
}
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
@@ -307,12 +315,53 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private createPool(poolConfig: PoolConfig): Pool {
|
||||
return createPool({
|
||||
const pool = createPool({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
enableKeepAlive: true,
|
||||
...poolConfig,
|
||||
multipleStatements: true,
|
||||
} as PoolConfig);
|
||||
|
||||
// Server-side kill switch for runaway reads: MySQL applies
|
||||
// max_execution_time to SELECT statements only, so this is
|
||||
// write-safe. Without it, a stalled database turns reads into
|
||||
// indefinite hangs that no client-side timeout ever converts
|
||||
// into a failure. 0 disables.
|
||||
const selectTimeoutMs = Math.floor(
|
||||
Number(
|
||||
this.config.database?.selectTimeoutMs ??
|
||||
DEFAULT_SELECT_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
if (selectTimeoutMs > 0) {
|
||||
pool.on('connection', (conn) => {
|
||||
conn.query(
|
||||
`SET SESSION max_execution_time = ${selectTimeoutMs}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private createPrimaryBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reinitialize the primary pool (e.g. after a health-check failure). */
|
||||
@@ -328,11 +377,11 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.dbReplica = new SQLBatcher(this.primaryPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.primaryPool);
|
||||
}
|
||||
|
||||
if (previous && previous !== this.primaryPool) {
|
||||
@@ -346,7 +395,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
const previous = this.replicaPool;
|
||||
this.replicaPool = this.createPool(this.config.database.replica);
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
if (
|
||||
previous &&
|
||||
@@ -362,11 +411,14 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static isRetriableError(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
return isRetriableError(error);
|
||||
}
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
/** Replica failures worth retrying on the primary: batcher load-shed
|
||||
* or transient connection errors — never deterministic SQL errors. */
|
||||
private static isFailoverWorthy(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
return code === 'dbBatchFailed' || isRetriableError(error);
|
||||
}
|
||||
|
||||
async readWithRetry(
|
||||
|
||||
@@ -18,10 +18,19 @@
|
||||
*/
|
||||
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
isNeverSentError,
|
||||
isRetriableError,
|
||||
} from './retriableErrors.js';
|
||||
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
||||
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
const DEFAULT_COOLDOWN_MS = 5_000;
|
||||
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000;
|
||||
const ACQUIRE_ATTEMPTS = 3;
|
||||
const ITEM_RETRY_ATTEMPTS = 2;
|
||||
const RETRY_BASE_BACKOFF_MS = 100;
|
||||
const FALLBACK_RETRY_CONCURRENCY = 8;
|
||||
|
||||
const meter = metrics.getMeter('puter-backend');
|
||||
@@ -53,6 +62,15 @@ const fallbackItemFailuresCounter = meter.createCounter(
|
||||
'Per-item failures observed during SQLBatcher per-item retry',
|
||||
},
|
||||
);
|
||||
const fallbackItemRetriesCounter = meter.createCounter(
|
||||
'sql_batcher.fallback.item_retries',
|
||||
{
|
||||
description:
|
||||
'Transient per-item failures retried during SQLBatcher fallback',
|
||||
},
|
||||
);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
@@ -61,18 +79,43 @@ export class SQLBatcher {
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
cooldownMs;
|
||||
poolLabel;
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
timeouts = [];
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
|
||||
/**
|
||||
* @param {object} dbPool mysql2 pool
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxTimeInQueue] ms an item may wait before flush
|
||||
* @param {number} [opts.maxBatchSize] items coalesced per flush
|
||||
* @param {number} [opts.maxQueueSize] drop-oldest high-water mark
|
||||
* @param {number} [opts.failureThreshold] consecutive failures to open the breaker
|
||||
* @param {number} [opts.cooldownMs] breaker open duration after last failure
|
||||
* @param {'primary'|'replica'} [opts.poolLabel] role label on metrics; in
|
||||
* single-node setups the 'replica' batcher shares the primary pool, so
|
||||
* this reflects the read/write role rather than a physical instance
|
||||
* @param {boolean} [opts.readOnly] this batcher only ever carries SELECTs,
|
||||
* so any transient failure is safe to retry
|
||||
* @param {number} [opts.acquireTimeoutMs] max wait for a pooled
|
||||
* connection; 0 disables the bound
|
||||
*/
|
||||
constructor(
|
||||
dbPool,
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
poolLabel = 'primary',
|
||||
readOnly = false,
|
||||
acquireTimeoutMs = DEFAULT_ACQUIRE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
@@ -80,6 +123,10 @@ export class SQLBatcher {
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
this.cooldownMs = cooldownMs;
|
||||
this.poolLabel = poolLabel;
|
||||
this.readOnly = readOnly;
|
||||
this.acquireTimeoutMs = acquireTimeoutMs;
|
||||
this.#metricAttrs = { pool: poolLabel };
|
||||
}
|
||||
|
||||
async execute(sql, values) {
|
||||
@@ -90,9 +137,13 @@ export class SQLBatcher {
|
||||
return this;
|
||||
}
|
||||
|
||||
#createPublicBatchError() {
|
||||
// The public error is deliberately opaque (no SQL, no internals), but
|
||||
// `reason` distinguishes the load-shed path for logs and callers:
|
||||
// breakerOpen | queueOverflow | connAcquire.
|
||||
#createPublicBatchError(reason) {
|
||||
const error = new Error('Database operation failed');
|
||||
error.code = 'dbBatchFailed';
|
||||
error.reason = reason;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -106,8 +157,8 @@ export class SQLBatcher {
|
||||
|
||||
async query(sql, values) {
|
||||
if (this.#isBreakerOpen()) {
|
||||
enqueueRejectedCounter.add(1);
|
||||
throw this.#createPublicBatchError();
|
||||
enqueueRejectedCounter.add(1, this.#metricAttrs);
|
||||
throw this.#createPublicBatchError('breakerOpen');
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
@@ -117,8 +168,8 @@ export class SQLBatcher {
|
||||
// to have already exceeded any caller-side timeout anyway.
|
||||
while (this.queue.length >= this.maxQueueSize) {
|
||||
const dropped = this.queue.shift();
|
||||
dropped.reject(this.#createPublicBatchError());
|
||||
enqueueDroppedCounter.add(1);
|
||||
dropped.reject(this.#createPublicBatchError('queueOverflow'));
|
||||
enqueueDroppedCounter.add(1, this.#metricAttrs);
|
||||
}
|
||||
|
||||
this.queue.push({
|
||||
@@ -142,6 +193,61 @@ export class SQLBatcher {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Bounded wait for a pooled connection. Without a bound, a stalled
|
||||
// database turns every flush into an indefinite hang — nothing fails,
|
||||
// so neither the breaker nor callers' own timeouts ever engage.
|
||||
#getConnectionWithTimeout() {
|
||||
const acquire = this.dbPool.promise().getConnection();
|
||||
if (!this.acquireTimeoutMs) return acquire;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
// A connection that arrives late must go back to the pool.
|
||||
acquire.then(
|
||||
(conn) => conn.release(),
|
||||
() => {},
|
||||
);
|
||||
const error = new Error(
|
||||
'Timed out acquiring database connection',
|
||||
);
|
||||
error.code = POOL_ACQUIRE_TIMEOUT;
|
||||
reject(error);
|
||||
}, this.acquireTimeoutMs);
|
||||
|
||||
acquire.then(
|
||||
(conn) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
},
|
||||
(err) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Acquisition failures never sent a statement, so retrying is always
|
||||
// safe regardless of what the batch contains.
|
||||
async #acquireConnection() {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await this.#getConnectionWithTimeout();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < ACQUIRE_ATTEMPTS) {
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
@@ -154,17 +260,17 @@ export class SQLBatcher {
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.dbPool.promise().getConnection();
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
this.#consecutiveFailures++;
|
||||
this.#lastFailureAt = Date.now();
|
||||
flushFailureCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
console.warn(
|
||||
'SQLBatcher could not acquire connection for flush:',
|
||||
error,
|
||||
);
|
||||
for (const b of batch) {
|
||||
b.reject(this.#createPublicBatchError());
|
||||
b.reject(this.#createPublicBatchError('connAcquire'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -206,8 +312,8 @@ export class SQLBatcher {
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
flushFailureCounter.add(1);
|
||||
fallbackInvocationsCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
const settled = new Array(batch.length);
|
||||
let cursor = 0;
|
||||
@@ -216,17 +322,7 @@ export class SQLBatcher {
|
||||
async () => {
|
||||
while (cursor < batch.length) {
|
||||
const i = cursor++;
|
||||
const b = batch[i];
|
||||
try {
|
||||
settled[i] = {
|
||||
ok: true,
|
||||
value: await this.dbPool
|
||||
.promise()
|
||||
.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
settled[i] = { ok: false, error };
|
||||
}
|
||||
settled[i] = await this.#runFallbackItem(batch[i]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -246,7 +342,7 @@ export class SQLBatcher {
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
fallbackItemFailuresCounter.add(failureCount);
|
||||
fallbackItemFailuresCounter.add(failureCount, this.#metricAttrs);
|
||||
}
|
||||
|
||||
// Only escalate the breaker when the database itself looks unhealthy
|
||||
@@ -259,4 +355,39 @@ export class SQLBatcher {
|
||||
this.#consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Run one fallback item, retrying transient failures with backoff.
|
||||
// A read-only batcher may retry anything transient; a batcher that
|
||||
// carries writes only retries failures where the statement provably
|
||||
// never reached the server — a write that died mid-flight may have
|
||||
// committed, and re-running it would double-apply.
|
||||
async #runFallbackItem(b) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await connection.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error)
|
||||
: isNeverSentError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
attempt++;
|
||||
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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 { SQLBatcher } from './SQLBatcher.js';
|
||||
|
||||
const makeError = (code: string, message = code): Error & { code: string } => {
|
||||
const error = new Error(message) as Error & { code: string };
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
interface FakeConnection {
|
||||
beginTransaction: ReturnType<typeof vi.fn>;
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
rollback: ReturnType<typeof vi.fn>;
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
// The batch flush sends one coalesced multi-statement (always suffixed
|
||||
// `; SELECT 1`); fallback items send their original single statement.
|
||||
const isBatchQuery = (sql: string) => sql.endsWith('; SELECT 1');
|
||||
|
||||
const makeConnection = (
|
||||
onQuery: (sql: string, values: unknown[]) => unknown,
|
||||
): FakeConnection => ({
|
||||
beginTransaction: vi.fn(async () => {}),
|
||||
query: vi.fn(async (sql: string, values: unknown[]) => onQuery(sql, values)),
|
||||
commit: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
release: vi.fn(),
|
||||
});
|
||||
|
||||
const makePool = (connection: FakeConnection | (() => Promise<FakeConnection>)) => {
|
||||
const getConnection = vi.fn(async () =>
|
||||
typeof connection === 'function' ? connection() : connection,
|
||||
);
|
||||
return {
|
||||
pool: { promise: () => ({ getConnection }) },
|
||||
getConnection,
|
||||
};
|
||||
};
|
||||
|
||||
// Happy-path onQuery: batch returns one result row-set per statement plus
|
||||
// the trailing SELECT 1 row-set.
|
||||
const happyBatch = (sql: string) => {
|
||||
if (!isBatchQuery(sql)) throw new Error('unexpected fallback query');
|
||||
const statements = sql.split(';').length - 1;
|
||||
return [
|
||||
Array.from({ length: statements + 1 }, (_, i) => [{ n: i }]),
|
||||
undefined,
|
||||
];
|
||||
};
|
||||
|
||||
describe('SQLBatcher', () => {
|
||||
it('resolves each batched item with its own result', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(conn.commit).toHaveBeenCalledTimes(1);
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
maxQueueSize: 1,
|
||||
});
|
||||
|
||||
const first = batcher.query('SELECT a', []);
|
||||
const second = batcher.query('SELECT b', []);
|
||||
|
||||
await expect(first).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'queueOverflow',
|
||||
});
|
||||
await expect(second).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects with reason connAcquire after exhausting acquisition retries', async () => {
|
||||
const getConnection = vi.fn(async () => {
|
||||
throw makeError('ECONNREFUSED');
|
||||
});
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('bounds connection acquisition and rejects when the pool never answers', async () => {
|
||||
const getConnection = vi.fn(
|
||||
() => new Promise(() => {}), // pool never yields a connection
|
||||
);
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
acquireTimeoutMs: 30,
|
||||
});
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('opens the breaker after consecutive failures and rejects with reason breakerOpen', async () => {
|
||||
// Batch and fallback both fail with an ambiguous (non-retriable for
|
||||
// writes) connection error, so no item gets through.
|
||||
const conn = makeConnection(() => {
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
await expect(batcher.query('INSERT y', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'breakerOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries transient fallback failures when readOnly', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNRESET');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const result = await batcher.query('SELECT a', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('does not retry ambiguous failures on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
expect(fallbackAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('retries never-sent failures even on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNREFUSED');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const result = await batcher.query('INSERT x', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_DUP_ENTRY');
|
||||
fallbackAttempts++;
|
||||
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const dup = batcher.query('INSERT dup', []);
|
||||
const fine = batcher.query('INSERT fine', []);
|
||||
await expect(dup).rejects.toMatchObject({ code: 'ER_DUP_ENTRY' });
|
||||
await expect(fine).resolves.toBeTruthy();
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
|
||||
// One fallback item succeeded, so the breaker must stay closed.
|
||||
const conn2 = makeConnection(happyBatch);
|
||||
// reuse same batcher/pool: next query must not be rejected upfront
|
||||
conn.query.mockImplementation(conn2.query.getMockImplementation()!);
|
||||
await expect(batcher.query('SELECT a', [])).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Error code set by the pool-acquisition timeout in SQLBatcher. */
|
||||
export const POOL_ACQUIRE_TIMEOUT = 'POOL_ACQUIRE_TIMEOUT';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Failures where the statement provably never reached the server, so a
|
||||
* retry can never double-apply it — safe even for writes. Anything that
|
||||
* can occur after the statement was sent (resets, protocol drops) is
|
||||
* deliberately excluded: the server may have committed before the
|
||||
* connection died.
|
||||
*/
|
||||
const NEVER_SENT_ERROR_CODES = new Set([
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
const errorCode = (error: unknown): string | undefined =>
|
||||
(error as { code?: string } | null)?.code;
|
||||
|
||||
/**
|
||||
* Transient connection-level failures worth retrying — but only for
|
||||
* statements that are safe to run twice (reads). Row-level errors
|
||||
* (duplicate key, constraint violations) are deterministic and never
|
||||
* match.
|
||||
*/
|
||||
export const isRetriableError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
};
|
||||
|
||||
export const isNeverSentError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && NEVER_SENT_ERROR_CODES.has(code));
|
||||
};
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -347,7 +348,15 @@ export class DDBClient extends PuterClient {
|
||||
pageKey?: Record<string, unknown>,
|
||||
index = '',
|
||||
consistentRead = false,
|
||||
options?: { beginsWith?: { key: string; value: string } },
|
||||
options?: {
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
filter?: {
|
||||
expression: string;
|
||||
values?: Record<string, unknown>;
|
||||
names?: Record<string, string>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const keyExpressionParts = Object.keys(keys).map(
|
||||
(key) => `#${key} = :${key}`,
|
||||
@@ -378,6 +387,11 @@ export class DDBClient extends PuterClient {
|
||||
expressionAttributeNames[`#${beginsKey}`] = beginsKey;
|
||||
}
|
||||
|
||||
if (options?.filter) {
|
||||
Object.assign(expressionAttributeValues, options.filter.values);
|
||||
Object.assign(expressionAttributeNames, options.filter.names);
|
||||
}
|
||||
|
||||
const command = new QueryCommand({
|
||||
TableName: table,
|
||||
...(!index ? {} : { IndexName: index }),
|
||||
@@ -387,6 +401,10 @@ export class DDBClient extends PuterClient {
|
||||
ConsistentRead: consistentRead,
|
||||
...(!pageKey ? {} : { ExclusiveStartKey: pageKey }),
|
||||
...(!limit ? {} : { Limit: limit }),
|
||||
...(options?.filter
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
@@ -436,6 +454,7 @@ export class DDBClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
let alreadyExisted = false;
|
||||
try {
|
||||
const client = await this.#getDocumentClient();
|
||||
await client.send(new CreateTableCommand(params));
|
||||
@@ -443,14 +462,33 @@ export class DDBClient extends PuterClient {
|
||||
if ((error as Error)?.name !== 'ResourceInUseException') {
|
||||
throw error;
|
||||
}
|
||||
alreadyExisted = true;
|
||||
}
|
||||
|
||||
if (ttlAttribute) {
|
||||
await this.#deleteExpiredItems(
|
||||
params.TableName!,
|
||||
params.KeySchema!,
|
||||
ttlAttribute,
|
||||
);
|
||||
// Only sweep a table that was already there. `CreateTable` returns
|
||||
// while the table is still CREATING, so scanning it right away races
|
||||
// its transition to ACTIVE and intermittently throws
|
||||
// `ResourceNotFoundException` — and a table we just created holds no
|
||||
// items to expire anyway.
|
||||
if (ttlAttribute && alreadyExisted) {
|
||||
try {
|
||||
await this.#deleteExpiredItems(
|
||||
params.TableName!,
|
||||
params.KeySchema!,
|
||||
ttlAttribute,
|
||||
);
|
||||
} catch (error) {
|
||||
// The sweep is opportunistic cleanup, so a table that isn't
|
||||
// queryable yet must never fail boot. Emulators differ on
|
||||
// whether `CreateTable` reports an existing table as in-use,
|
||||
// which can still land us here on a CREATING table.
|
||||
if ((error as Error)?.name !== 'ResourceNotFoundException') {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`[ddb] skipped TTL sweep for ${params.TableName}: table not queryable yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,18 @@ export interface SendMailOptions {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
/** Optional transport recipients when they differ from visible headers. */
|
||||
envelope?: {
|
||||
from?: string;
|
||||
to: string;
|
||||
};
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
/** Extra message headers (e.g. List-Unsubscribe), passed to the transport. */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('EventClient', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// Each test gets a fresh key so listeners registered by earlier tests
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
*
|
||||
* Domain objects (User, Actor, FSEntry, Socket, ...) are typed as `unknown`
|
||||
* here on purpose — pulling their real types in would couple this module to
|
||||
* most of the backend and risk import cycles. Refine at the listener if
|
||||
* you need narrowed access.
|
||||
* most of the backend and risk import cycles. Refine at the listener if you
|
||||
* need narrowed access.
|
||||
*
|
||||
* Conventions:
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject;
|
||||
* they tend to grow listener-specific fields, so they accept extras via
|
||||
* an index signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope
|
||||
* the SocketService fans out to user-scoped channels.
|
||||
*
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject; they tend
|
||||
* to grow listener-specific fields, so they accept extras via an index
|
||||
* signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope the
|
||||
* SocketService fans out to user-scoped channels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -148,13 +149,16 @@ export type EventMap = {
|
||||
trail?: Array<string>;
|
||||
/**
|
||||
* Set by the abuse harness for flagged signups — the id under which the
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back on
|
||||
* the request for log / support correlation.
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back
|
||||
* on the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/** Set by the abuse harness — require SMS phone verification post-signup. */
|
||||
/**
|
||||
* Set by the abuse harness — require SMS phone verification
|
||||
* post-signup.
|
||||
*/
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
requires_card_verification?: boolean;
|
||||
@@ -334,9 +338,13 @@ export type EventMap = {
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
// https:// URL becomes a signed URL) and falls back to `uuid`/`uid` to
|
||||
// migrate inline data-URL thumbnails. Declared to match: the previous
|
||||
// `uri` member was never emitted nor read.
|
||||
'thumbnail.read': {
|
||||
uri: string;
|
||||
size?: string;
|
||||
uuid?: string;
|
||||
uid?: string;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
'thumbnail.created': { url: string };
|
||||
@@ -406,9 +414,9 @@ 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`).
|
||||
* 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;
|
||||
@@ -474,11 +482,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
@@ -28,6 +29,11 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
signStepUpToken,
|
||||
STEP_UP_COOKIE_NAME,
|
||||
stepUpCookieOptions,
|
||||
} from '../../core/http/middleware/stepUpSession.js';
|
||||
import {
|
||||
createUserProtectedGate,
|
||||
createWebSessionActorGate,
|
||||
@@ -102,14 +108,13 @@ const RESERVED_USERNAMES = new Set([
|
||||
* Auth controller — login/logout, permission grants/revokes, session
|
||||
* management, OTP, and permission checks.
|
||||
*
|
||||
* Routes are declared via decorators (@Get/@Post on each handler). The
|
||||
* five `/user-protected/*` and `/user-protected/delete-own-user` routes
|
||||
* also need a per-instance `createUserProtectedGate(...)` middleware
|
||||
* built from `this.config / this.stores / this.services`, which can't
|
||||
* live in a static decorator literal — those are wired imperatively in
|
||||
* the `registerRoutes` override below. The override also re-runs the
|
||||
* default decorator-walker logic so the rest of the routes register
|
||||
* normally.
|
||||
* Routes are declared via decorators (@Get/@Post on each handler). The five
|
||||
* `/user-protected/*` and `/user-protected/delete-own-user` routes also need a
|
||||
* per-instance `createUserProtectedGate(...)` middleware built from
|
||||
* `this.config / this.stores / this.services`, which can't live in a static
|
||||
* decorator literal — those are wired imperatively in the `registerRoutes`
|
||||
* override below. The override also re-runs the default decorator-walker logic
|
||||
* so the rest of the routes register normally.
|
||||
*/
|
||||
@Controller('')
|
||||
export class AuthController extends PuterController {
|
||||
@@ -964,6 +969,11 @@ export class AuthController extends PuterController {
|
||||
// a stale value would re-authenticate the next request.
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
// Drop any step-up elevation too, so it can't reactivate on a shared
|
||||
// machine.
|
||||
res.clearCookie(STEP_UP_COOKIE_NAME, {
|
||||
...(this.config.domain ? { domain: this.config.domain } : {}),
|
||||
});
|
||||
|
||||
// Remove the session (fire-and-forget)
|
||||
if (req.token) {
|
||||
@@ -1068,7 +1078,13 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (String(user.email_confirm_code) !== String(code)) {
|
||||
// Reject before comparing when no code is stored: `String(null)` would
|
||||
// otherwise equal a submitted `"null"` and confirm the email without
|
||||
// the real code.
|
||||
if (
|
||||
!user.email_confirm_code ||
|
||||
String(user.email_confirm_code) !== String(code)
|
||||
) {
|
||||
res.json({
|
||||
email_confirmed: false,
|
||||
original_client_socket_id,
|
||||
@@ -1125,10 +1141,10 @@ export class AuthController extends PuterController {
|
||||
* single greppable line tying that id to the real reason (so support can
|
||||
* look it up in CloudWatch with the id the user quotes), stores the same
|
||||
* record in KV under `sms-send-error:<error_id>` for a week (the admin
|
||||
* abuse page looks it up there without needing log access), and returns
|
||||
* the `HttpError` with the id attached as `error_id` for the GUI to
|
||||
* surface. The phone number is deliberately omitted from the log line and
|
||||
* the KV record (PII); the user + country are enough to correlate.
|
||||
* abuse page looks it up there without needing log access), and returns the
|
||||
* `HttpError` with the id attached as `error_id` for the GUI to surface.
|
||||
* The phone number is deliberately omitted from the log line and the KV
|
||||
* record (PII); the user + country are enough to correlate.
|
||||
*/
|
||||
private async smsSendError(
|
||||
statusCode: number,
|
||||
@@ -1635,11 +1651,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
/**
|
||||
* Start card verification for the calling user. Pure mechanism: the
|
||||
* endpoint emits `puter.card-verification.setup` and a payments
|
||||
* extension fills in the client credentials — the OSS backend holds
|
||||
* no provider knowledge or config. Phone verification (when required)
|
||||
* must be completed first; the ordering is enforced here so a client
|
||||
* can't skip the cheaper gate.
|
||||
* endpoint emits `puter.card-verification.setup` and a payments extension
|
||||
* fills in the client credentials — the OSS backend holds no provider
|
||||
* knowledge or config. Phone verification (when required) must be completed
|
||||
* first; the ordering is enforced here so a client can't skip the cheaper
|
||||
* gate.
|
||||
*/
|
||||
@Post('/card-verification/setup', {
|
||||
subdomain: ['api', ''],
|
||||
@@ -1750,9 +1766,9 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete card verification. The client confirms the setup intent with
|
||||
* the payment provider directly, then posts the resulting id here; the
|
||||
* payments extension checks it (and applies its own abuse limits) via
|
||||
* Complete card verification. The client confirms the setup intent with the
|
||||
* payment provider directly, then posts the resulting id here; the payments
|
||||
* extension checks it (and applies its own abuse limits) via
|
||||
* `puter.card-verification.confirm`. On success the gate clears exactly
|
||||
* like `/confirm-phone` clears the phone gate.
|
||||
*/
|
||||
@@ -2657,12 +2673,95 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared input validation for the user-app grant/revoke handlers, which
|
||||
* accept a caller-supplied `origin` as an alternative to `app_uid`. All
|
||||
* parameters are optional-but-typed: presence is enforced by the handlers'
|
||||
* own `app_uid`/`permission` checks after origin resolution.
|
||||
*
|
||||
* These are bounded only against absurd input. The width of the column a
|
||||
* permission lands in is enforced by the permission service instead, on the
|
||||
* _rewritten_ string: `fs:/path:mode` is rewritten to `fs:<uuid>:mode`
|
||||
* before it is stored, so a deep path is a ~45-character row and must not
|
||||
* be rejected for the length of the path the caller typed.
|
||||
*/
|
||||
#validateAppPermissionParams(params: {
|
||||
app_uid?: unknown;
|
||||
origin?: unknown;
|
||||
permission?: unknown;
|
||||
extra?: unknown;
|
||||
meta?: unknown;
|
||||
}): void {
|
||||
const MAX_LEN = 4096;
|
||||
for (const key of ['app_uid', 'origin', 'permission'] as const) {
|
||||
const value = params[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value !== 'string' || value.length > MAX_LEN) {
|
||||
throw new HttpError(400, `Invalid \`${key}\``, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
// `extra` and `meta` are forwarded into the audit row and read as
|
||||
// objects downstream. A non-object would fault *after* the grant is
|
||||
// committed, so reject it up front. `null` is treated as absent, the
|
||||
// same as the string parameters above.
|
||||
for (const key of ['extra', 'meta'] as const) {
|
||||
const value = params[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new HttpError(400, `Invalid \`${key}\``, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a caller-supplied `origin` to the uid of a _registered_ app.
|
||||
*
|
||||
* `appUidFromOrigin` synthesises a deterministic `app-<uuidv5>` uid for an
|
||||
* origin that has no app row yet, and the permission services resolve their
|
||||
* identifier as uid-_or-name_. Passing a synthetic uid straight through
|
||||
* would therefore let whoever registered an app under that literal name
|
||||
* collect a grant the user made to the origin — the uid is derived from a
|
||||
* published namespace constant, so it can be computed and squatted offline.
|
||||
* Only a uid that names an existing app row is accepted.
|
||||
*
|
||||
* An `origin` supplied alongside an `app_uid` takes precedence over it (see
|
||||
* the grant/revoke handlers). The origin is what a consent prompt shows the
|
||||
* user, so it — not a uid travelling beside it — has to decide who receives
|
||||
* the grant; otherwise a caller could name one app on screen and grant to
|
||||
* another. No caller sends both with different intent.
|
||||
*/
|
||||
async #registeredAppUidFromOrigin(origin: string): Promise<string> {
|
||||
const uid = await this.services.auth.appUidFromOrigin(origin);
|
||||
const app = await this.stores.app.getByUid(uid);
|
||||
if (!app) {
|
||||
throw new HttpError(404, `entity_not_found: app:${uid}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
return app.uid;
|
||||
}
|
||||
|
||||
@Post('/auth/grant-user-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
})
|
||||
async handleGrantUserApp(req: Request, res: Response): Promise<void> {
|
||||
const { app_uid, permission, extra, meta } = req.body;
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, extra, meta } = req.body;
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
permission,
|
||||
extra,
|
||||
meta,
|
||||
});
|
||||
if (origin) {
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -2672,8 +2771,8 @@ export class AuthController extends PuterController {
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
extra,
|
||||
meta,
|
||||
extra ?? undefined,
|
||||
meta ?? undefined,
|
||||
);
|
||||
res.json({});
|
||||
}
|
||||
@@ -2733,7 +2832,17 @@ export class AuthController extends PuterController {
|
||||
requireUserActor: true,
|
||||
})
|
||||
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
||||
const { app_uid, permission, meta } = req.body;
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, meta } = req.body;
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
permission,
|
||||
meta,
|
||||
});
|
||||
if (origin) {
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -2743,14 +2852,14 @@ export class AuthController extends PuterController {
|
||||
await this.services.permission.revokeUserAppAll(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
meta,
|
||||
meta ?? undefined,
|
||||
);
|
||||
} else {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
meta,
|
||||
meta ?? undefined,
|
||||
);
|
||||
}
|
||||
res.json({});
|
||||
@@ -2893,7 +3002,15 @@ export class AuthController extends PuterController {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, extra, meta } = req.body;
|
||||
if (origin && !app_uid) {
|
||||
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
||||
// Registered apps only, for the same reason the user-app handlers
|
||||
// insist on it: a synthesised `app-<uuidv5(origin)>` is resolved
|
||||
// downstream as uid-*or-name*, so it would land on whoever
|
||||
// registered an app under that literal name. A dev-app grant is
|
||||
// scanned with the issuer's authority for anyone running as that
|
||||
// app, so that hands this user's permission to the squatter.
|
||||
// Without a squatter the synthetic uid resolves to nothing and
|
||||
// this 404s regardless, so nothing legitimate changes.
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
@@ -2918,7 +3035,8 @@ export class AuthController extends PuterController {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, meta } = req.body;
|
||||
if (origin && !app_uid) {
|
||||
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
||||
// Registered apps only — see handleGrantDevApp.
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
}
|
||||
if (!app_uid || !permission) {
|
||||
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
|
||||
@@ -2931,13 +3049,14 @@ export class AuthController extends PuterController {
|
||||
app_uid,
|
||||
meta,
|
||||
);
|
||||
} else {
|
||||
await this.services.permission.revokeDevAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
meta,
|
||||
);
|
||||
}
|
||||
await this.services.permission.revokeDevAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
permission,
|
||||
meta,
|
||||
);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@@ -2953,17 +3072,22 @@ export class AuthController extends PuterController {
|
||||
|
||||
const [appPerms, userPermsOut, userPermsIn] = await Promise.all([
|
||||
db.read(
|
||||
'SELECT `app_uid`, `permission`, `extra` FROM `user_to_app_permissions` WHERE `user_id` = ?',
|
||||
// The permissions table stores the numeric `app_id` FK; the
|
||||
// public shape exposes the app's `uid`.
|
||||
'SELECT a.`uid` AS app_uid, p.`permission`, p.`extra` ' +
|
||||
'FROM `user_to_app_permissions` p ' +
|
||||
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
|
||||
'WHERE p.`user_id` = ?',
|
||||
[userId],
|
||||
),
|
||||
db.read(
|
||||
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
|
||||
'JOIN `user` u ON u.`id` = p.`target_user_id` WHERE p.`issuer_user_id` = ?',
|
||||
'JOIN `user` u ON u.`id` = p.`holder_user_id` WHERE p.`issuer_user_id` = ?',
|
||||
[userId],
|
||||
),
|
||||
db.read(
|
||||
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
|
||||
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`target_user_id` = ?',
|
||||
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`holder_user_id` = ?',
|
||||
[userId],
|
||||
),
|
||||
]);
|
||||
@@ -3037,7 +3161,13 @@ export class AuthController extends PuterController {
|
||||
|
||||
let app = await this.stores.app.getByUid(app_uid);
|
||||
if (!app && resolvedFromOrigin) {
|
||||
app = await this.stores.app.createFromOrigin(app_uid, origin);
|
||||
// Hosted-subdomain origins get the site owner stamped as the
|
||||
// app's creator at bootstrap; external origins stay unowned.
|
||||
const ownerUserId =
|
||||
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
|
||||
app = await this.stores.app.createFromOrigin(app_uid, origin, {
|
||||
ownerUserId,
|
||||
});
|
||||
}
|
||||
if (!app) {
|
||||
throw new HttpError(404, `App ${app_uid} does not exist`, {
|
||||
@@ -3546,13 +3676,9 @@ export class AuthController extends PuterController {
|
||||
@Get('/group/list', { subdomain: 'api', requireUserActor: true })
|
||||
async handleGroupList(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
const groupStore = this.stores.group as unknown as {
|
||||
listByOwner: (id: number) => Promise<unknown[]>;
|
||||
listByMember: (id: number) => Promise<unknown[]>;
|
||||
};
|
||||
const [owned, member] = await Promise.all([
|
||||
groupStore.listByOwner(userId),
|
||||
groupStore.listByMember(userId),
|
||||
this.stores.group.listGroupsWithOwner(userId),
|
||||
this.stores.group.listGroupsWithMember(userId),
|
||||
]);
|
||||
res.json({
|
||||
owned_groups: owned,
|
||||
@@ -3596,6 +3722,15 @@ export class AuthController extends PuterController {
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
async handleSessionSyncCookie(req: Request, res: Response): Promise<void> {
|
||||
// This route installs a session cookie, so the token has to come from
|
||||
// page script on our own origin rather than from the URL.
|
||||
if (req.tokenSource !== 'header') {
|
||||
throw new HttpError(
|
||||
401,
|
||||
'This endpoint requires an Authorization header.',
|
||||
{ legacyCode: 'token_auth_failed' },
|
||||
);
|
||||
}
|
||||
if (!req.actor?.session?.uid) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
@@ -3616,6 +3751,91 @@ export class AuthController extends PuterController {
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// -- Step-up ("elevation"), wired below --------------------------
|
||||
//
|
||||
// Mints the second-factor cookie for a session that re-proves identity: a
|
||||
// fresh TOTP code when 2FA is enabled, otherwise the account password.
|
||||
// Privileged endpoints require it on top of the session, so a leaked session
|
||||
// alone can't exercise them. Accounts with neither credential (no password
|
||||
// and 2FA disabled) can't elevate.
|
||||
|
||||
async handleElevate(req: Request, res: Response): Promise<void> {
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
force: true,
|
||||
});
|
||||
if (!user)
|
||||
throw new HttpError(404, 'User not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
if (user.suspended)
|
||||
throw new HttpError(403, 'Account suspended.', {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
|
||||
if (user.otp_enabled) {
|
||||
const code = req.body?.code;
|
||||
if (!code)
|
||||
throw new HttpError(400, 'code is required.', {
|
||||
legacyCode: 'bad_request',
|
||||
fields: { factor: 'otp' },
|
||||
});
|
||||
if (
|
||||
!verifyOtp(
|
||||
user.username,
|
||||
user.otp_secret as string,
|
||||
String(code),
|
||||
)
|
||||
)
|
||||
throw new HttpError(401, 'Incorrect code.', {
|
||||
legacyCode: 'code_mismatch' as never,
|
||||
fields: { factor: 'otp' },
|
||||
});
|
||||
} else if (user.password) {
|
||||
const password = req.body?.password;
|
||||
if (!password || typeof password !== 'string')
|
||||
throw new HttpError(400, 'Password is required.', {
|
||||
legacyCode: 'password_required',
|
||||
fields: { factor: 'password' },
|
||||
});
|
||||
const match = await bcrypt.compare(
|
||||
password,
|
||||
user.password as string,
|
||||
);
|
||||
if (!match)
|
||||
throw new HttpError(401, 'Incorrect password.', {
|
||||
legacyCode: 'password_mismatch',
|
||||
fields: { factor: 'password' },
|
||||
});
|
||||
} else {
|
||||
// Neither credential on file (e.g. an account that only ever
|
||||
// authenticated through an external identity provider).
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This account has no credential to re-authenticate with. Set a password or enable two-factor authentication first.',
|
||||
{ legacyCode: 'elevation_unavailable' as never },
|
||||
);
|
||||
}
|
||||
|
||||
const token = signStepUpToken(this.services.token, user as never);
|
||||
res.cookie(
|
||||
STEP_UP_COOKIE_NAME,
|
||||
token,
|
||||
stepUpCookieOptions(this.config),
|
||||
);
|
||||
|
||||
// A browser reads its elevation back from the httpOnly cookie and never
|
||||
// needs the raw value; handing it to page JS would put the second factor
|
||||
// within reach of an XSS. API clients have no cookie jar, so they get the
|
||||
// token to send back as `x-puter-elevation`. Both paths proved the same
|
||||
// password/TOTP — this only avoids needless exposure, it isn't a gate.
|
||||
const cookieName = this.config.cookie_name ?? 'puter_token';
|
||||
const usedSessionCookie =
|
||||
!!req.token && req.token === req.cookies?.[cookieName];
|
||||
res.json(
|
||||
usedSessionCookie ? { elevated: true } : { elevated: true, token },
|
||||
);
|
||||
}
|
||||
|
||||
// -- Delete own account (user-protected, wired below) ------------
|
||||
//
|
||||
// Purge S3 objects + fsentries first, then the user row. FK
|
||||
@@ -3628,6 +3848,9 @@ export class AuthController extends PuterController {
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
res.clearCookie(STEP_UP_COOKIE_NAME, {
|
||||
...(this.config.domain ? { domain: this.config.domain } : {}),
|
||||
});
|
||||
await this.#cascadeDeleteUser(userId);
|
||||
res.json({ success: true });
|
||||
}
|
||||
@@ -3774,6 +3997,37 @@ export class AuthController extends PuterController {
|
||||
(req, res) => this.handleDeleteOwnUser(req, res),
|
||||
);
|
||||
|
||||
// Step-up. Served on the root origin (browser form posts same-origin)
|
||||
// and on `api` (SDK/script clients, which have no cookie jar and send a
|
||||
// bearer). Deliberately NOT cookie-gated: the password/TOTP in the body
|
||||
// is the control — a stolen token alone can't satisfy it, and it's also
|
||||
// what makes CSRF a non-issue. `requireUserActor` still keeps app and
|
||||
// access-token actors out, so an access token can never mint an
|
||||
// elevation for its issuer.
|
||||
router.post(
|
||||
'/auth/elevate',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: [
|
||||
{
|
||||
scope: 'elevate',
|
||||
limit: 10,
|
||||
window: 15 * 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
{
|
||||
scope: 'elevate-ip',
|
||||
limit: 40,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
],
|
||||
},
|
||||
(req, res) => this.handleElevate(req, res),
|
||||
);
|
||||
|
||||
const webSessionGate = createWebSessionActorGate();
|
||||
|
||||
router.post(
|
||||
@@ -3916,10 +4170,10 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-blocklist + extension-driven email validation.
|
||||
* Config blocklist (suffix match on cleaned email) blocks first; then
|
||||
* the `email.validate` event lets extensions (abuse) reject.
|
||||
* Throws HttpError(400) on rejection.
|
||||
* Config-blocklist + extension-driven email validation. Config blocklist
|
||||
* (suffix match on cleaned email) blocks first; then the `email.validate`
|
||||
* event lets extensions (abuse) reject. Throws HttpError(400) on
|
||||
* rejection.
|
||||
*/
|
||||
async #validateEmail(email: string): Promise<void> {
|
||||
if (
|
||||
@@ -3964,9 +4218,9 @@ export class AuthController extends PuterController {
|
||||
|
||||
/**
|
||||
* Per-IP enumeration clamp on `auth_id`-bearing auth requests. Separate
|
||||
* from the route-level rate limit so a tighter ceiling applies only to
|
||||
* the path that takes a uuid hint from the body — the normal login
|
||||
* path stays at its more generous limit.
|
||||
* from the route-level rate limit so a tighter ceiling applies only to the
|
||||
* path that takes a uuid hint from the body — the normal login path stays
|
||||
* at its more generous limit.
|
||||
*/
|
||||
async #checkAuthIdRateLimit(req: Request): Promise<void> {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
@@ -3984,12 +4238,11 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `auth_id` claim from a client-supplied reauth_token.
|
||||
* Returns null when no token was supplied. Throws on invalid/expired
|
||||
* tokens. The reauth_token is a server-signed JWT minted by the
|
||||
* authProbe at 401 time — accepting only the signed envelope (vs. a
|
||||
* raw UUID) means a leaked auth_id alone can't attach a session to
|
||||
* an existing account.
|
||||
* Extract the `auth_id` claim from a client-supplied reauth_token. Returns
|
||||
* null when no token was supplied. Throws on invalid/expired tokens. The
|
||||
* reauth_token is a server-signed JWT minted by the authProbe at 401 time —
|
||||
* accepting only the signed envelope (vs. a raw UUID) means a leaked
|
||||
* auth_id alone can't attach a session to an existing account.
|
||||
*/
|
||||
#extractAuthIdFromReauthToken(suppliedToken: unknown): string | null {
|
||||
if (suppliedToken === undefined || suppliedToken === null) return null;
|
||||
@@ -4003,18 +4256,17 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce a verified `auth_id` against the user the credential flow
|
||||
* has resolved. Caller has already extracted `auth_id` from the
|
||||
* server-signed reauth_token (or from an OTP-flow JWT). When the GUI
|
||||
* is forced through reauth, the 401 response embeds the reauth_token;
|
||||
* the client echoes it back so we can confirm the second login lands
|
||||
* on the same user row — critical for temp users, where a fresh
|
||||
* signup would otherwise mint a new account and strand their files.
|
||||
* Enforce a verified `auth_id` against the user the credential flow has
|
||||
* resolved. Caller has already extracted `auth_id` from the server-signed
|
||||
* reauth_token (or from an OTP-flow JWT). When the GUI is forced through
|
||||
* reauth, the 401 response embeds the reauth_token; the client echoes it
|
||||
* back so we can confirm the second login lands on the same user row —
|
||||
* critical for temp users, where a fresh signup would otherwise mint a new
|
||||
* account and strand their files.
|
||||
*
|
||||
* No `authId` supplied → no-op (normal login). Unknown `authId` →
|
||||
* 404 (mirrors username-not-found, avoids being an enumeration
|
||||
* oracle). Mismatch against the resolved user → 409
|
||||
* (`auth_id_mismatch`).
|
||||
* No `authId` supplied → no-op (normal login). Unknown `authId` → 404
|
||||
* (mirrors username-not-found, avoids being an enumeration oracle).
|
||||
* Mismatch against the resolved user → 409 (`auth_id_mismatch`).
|
||||
*/
|
||||
async #enforceAuthIdMatch(
|
||||
req: Request,
|
||||
|
||||
@@ -295,6 +295,157 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// A lifecycle hook *exists* on every driver (inherited from PuterDriver),
|
||||
// so the old `typeof driver[method] === 'function'` dispatch would have
|
||||
// invoked it. It must not be reachable over /drivers/call.
|
||||
it.each([
|
||||
'onServerStart',
|
||||
'onServerShutdown',
|
||||
'onServerPrepareShutdown',
|
||||
'getReportedCosts',
|
||||
'constructor',
|
||||
'toString',
|
||||
])('rejects the framework method %s with 404', async (method) => {
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq({ interface: 'puter-kvstore', method }, actor);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
|
||||
// Privileged ("godmode") apps call AI with the user's own session
|
||||
// token, so the AI drivers no longer set `noUserSession`: the
|
||||
// session actor reaches the permission scan (403 forbidden — no
|
||||
// perms granted) instead of the credential-shape rejection.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
|
||||
const base = await makeUserActor();
|
||||
const delegatedActors: Actor[] = [
|
||||
{ ...base, app: { uid: `app-${uuidv4()}` } },
|
||||
{
|
||||
...base,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: base,
|
||||
fullAccess: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const actor of delegatedActors) {
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
// Fresh actors hold no service:* perms, so the call still
|
||||
// 403s — but at the permission scan, proving the
|
||||
// credential-shape gate let the delegated actor through.
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a user-scoped worker session on the AI drivers', async () => {
|
||||
// Workers deployed without an app binding authenticate as a user
|
||||
// actor whose session row is kind='worker' — they must keep their
|
||||
// AI access (they fail later on permission here, not on
|
||||
// credential shape).
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
session: { uid: `sess-${uuidv4()}`, kind: 'worker' },
|
||||
};
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args: { messages: [] },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
|
||||
// puter-kvstore doesn't set the flag; the session actor reaches
|
||||
// the permission scan (403 forbidden — no perms granted) instead
|
||||
// of being rejected for its credential shape.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key: 'x', value: 'y' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with 403 when the actor lacks the service permission', async () => {
|
||||
// A fresh user has no service:* perms → permission.check returns
|
||||
// false → 403.
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -22,6 +23,7 @@ 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';
|
||||
import { assertNotUserSession } from '../../core/http/middleware/gates.js';
|
||||
import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
@@ -30,6 +32,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
@@ -114,15 +117,23 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
|
||||
@Controller('/drivers')
|
||||
export class DriverController extends PuterController {
|
||||
/** iface → Map<driverName, driverInstance> */
|
||||
/** Iface → Map<driverName, driverInstance> */
|
||||
#drivers = new Map<string, Map<string, DriverInstance>>();
|
||||
/** iface → default driver name */
|
||||
/** Iface → default driver name */
|
||||
#defaults = new Map<string, string>();
|
||||
/**
|
||||
* driver instance → resolved meta. Cached so the per-call rate-limit
|
||||
* lookup doesn't have to walk prototype chains on every request.
|
||||
* Driver instance → resolved meta. Cached so the per-call rate-limit lookup
|
||||
* doesn't have to walk prototype chains on every request.
|
||||
*/
|
||||
#meta = new WeakMap<DriverInstance, DriverMeta>();
|
||||
/**
|
||||
* Driver instance → the set of method names callable via `/drivers/call`.
|
||||
* Resolved once at registration (server startup) via
|
||||
* `resolveCallableMethods`; the request path only does a `Set.has` lookup.
|
||||
* This is what stops framework/lifecycle methods (`onServerStart`, etc.)
|
||||
* and `Object.prototype` members from being invoked by remote callers.
|
||||
*/
|
||||
#callableMethods = new WeakMap<DriverInstance, Set<string>>();
|
||||
|
||||
constructor(...args: ConstructorParameters<typeof PuterController>) {
|
||||
super(...args);
|
||||
@@ -201,14 +212,19 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
const fn = driver[method];
|
||||
if (typeof fn !== 'function') {
|
||||
// Only methods in the pre-resolved callable set are dispatchable.
|
||||
// This excludes framework/lifecycle hooks (onServerStart, etc.),
|
||||
// inherited base methods, and Object.prototype members, none of
|
||||
// which are part of any interface's RPC contract.
|
||||
const callable = this.#callableMethods.get(driver);
|
||||
if (!callable?.has(method)) {
|
||||
throw new HttpError(
|
||||
404,
|
||||
`Method '${method}' not found on driver '${ifaceName}'`,
|
||||
{ legacyCode: 'not_found' },
|
||||
);
|
||||
}
|
||||
const fn = driver[method];
|
||||
|
||||
// Resolve the concrete driver name for permission keys, falling
|
||||
// back through prototype metadata → instance field → requested name.
|
||||
@@ -219,6 +235,20 @@ export class DriverController extends PuterController {
|
||||
requestedDriver ??
|
||||
'unknown';
|
||||
|
||||
const driverMeta = this.#meta.get(driver);
|
||||
|
||||
// Drivers flagged `noUserSession` refuse the bare
|
||||
// account-session ("root") token: callers must present an app or
|
||||
// worker token, or an API token minted from the dashboard. This is
|
||||
// the per-driver counterpart of the `noUserSession` route option —
|
||||
// `/drivers/call` is one shared route, so the flag has to live on
|
||||
// the driver rather than in `RouteOptions`. Checked before the
|
||||
// permission scan so a session-token caller always gets the
|
||||
// credential-shape message, not a permission error.
|
||||
if (driverMeta?.noUserSession) {
|
||||
assertNotUserSession(req.actor);
|
||||
}
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
| PermissionService
|
||||
@@ -257,7 +287,6 @@ export class DriverController extends PuterController {
|
||||
// — we hook `res.finish` / `res.close` for that so streamed
|
||||
// responses hold their slot until the stream drains, and aborted
|
||||
// requests still give the slot back.
|
||||
const driverMeta = this.#meta.get(driver);
|
||||
const rateLimitSpec = resolveDriverMethodRateLimit(
|
||||
driverMeta?.rateLimit,
|
||||
method,
|
||||
@@ -499,10 +528,15 @@ export class DriverController extends PuterController {
|
||||
// Cache the resolved meta so the request hot-path can read the
|
||||
// per-method rate-limit spec without re-walking the prototype.
|
||||
this.#meta.set(instance, meta);
|
||||
// Register each alias pointing at the same instance. Legacy puter-js
|
||||
// calls that pass a provider id in the `driver` slot (e.g. the TTS
|
||||
// module sends `aws-polly` / `openai-tts` / `elevenlabs-tts` instead
|
||||
// of the unified `ai-tts`) resolve here; the handler sets
|
||||
// Resolve the callable RPC surface once, at startup. The request
|
||||
// path checks membership against this set instead of reflecting on
|
||||
// the live instance, so lifecycle hooks / inherited framework
|
||||
// methods can never be dispatched.
|
||||
this.#callableMethods.set(instance, resolveCallableMethods(instance));
|
||||
// Register each alias pointing at the same instance. Calls that pass
|
||||
// a provider id in the `driver` slot (e.g. `aws-polly` or
|
||||
// `openai-tts` instead of the unified `ai-tts`, as SDK bundles
|
||||
// predating the unified drivers do) resolve here; the handler sets
|
||||
// Context.driverName to the alias so the method can route to the
|
||||
// right internal provider.
|
||||
for (const alias of meta.aliases) {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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 { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* Route-level coverage for `GET /fs/readdir`. The unit tests call the handler
|
||||
* directly, which cannot catch a route that was never registered or a gate that
|
||||
* rejects the request — so this suite drives real HTTP over a listening server,
|
||||
* and authenticates purely through `?auth_token=` (no request headers), which is
|
||||
* the whole point of offering the read as a GET.
|
||||
*/
|
||||
describe('GET /fs/readdir over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const readdirUrl = (params: Record<string, string>) => {
|
||||
const url = new URL('/fs/readdir', env.apiOrigin);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
it('lists a directory authenticated only by a query token', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({ path: `/${username}`, auth_token: token }),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as Array<{
|
||||
name: string;
|
||||
uuid: string;
|
||||
}>;
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
// Default provisioned home directories.
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
for (const entry of body) {
|
||||
expect(entry.uuid).toEqual(expect.any(String));
|
||||
}
|
||||
});
|
||||
|
||||
it('paginates via query params', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({
|
||||
path: `/${username}`,
|
||||
auth_token: token,
|
||||
limit: '2',
|
||||
cursor: '',
|
||||
includeTotal: 'true',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const page = (await response.json()) as {
|
||||
items: unknown[];
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
expect(page.items.length).toBe(2);
|
||||
expect(typeof page.total).toBe('number');
|
||||
expect(page.cursor).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
const { username } = env.users.user;
|
||||
const response = await fetch(readdirUrl({ path: `/${username}` }));
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
expect(response.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('does not leak internal ids or storage columns over the wire', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
readdirUrl({ path: `/${username}`, auth_token: token }),
|
||||
);
|
||||
const raw = await response.text();
|
||||
const body = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
// Checked per-entry rather than by scanning the raw body: the nested
|
||||
// `associatedApp` payload legitimately carries its own `id` (v1 has
|
||||
// always exposed it), so a substring scan would false-alarm.
|
||||
for (const entry of body) {
|
||||
for (const field of [
|
||||
'id',
|
||||
'parentId',
|
||||
'userId',
|
||||
'associatedAppId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(entry).not.toHaveProperty(field);
|
||||
}
|
||||
}
|
||||
// No user-identifying data (emails, owner records) anywhere.
|
||||
expect(raw).not.toContain('@');
|
||||
expect(raw).not.toMatch(/"(email|owner|user_id|userId)"/);
|
||||
});
|
||||
|
||||
it('lists a nested subtree recursively over GET', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const base = `/${username}/Documents/http-recursive-${Date.now()}`;
|
||||
for (const path of [base, `${base}/a`, `${base}/a/b`]) {
|
||||
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, auth_token: token }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
readdirUrl({
|
||||
path: base,
|
||||
auth_token: token,
|
||||
recursive: 'true',
|
||||
depth: '5',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const page = (await response.json()) as {
|
||||
items: Array<{ path: string }>;
|
||||
};
|
||||
expect(
|
||||
page.items.map((e) => e.path.slice(base.length + 1)).sort(),
|
||||
).toEqual(['a', 'a/b']);
|
||||
});
|
||||
|
||||
it('mkdir does not return internal ids, storage columns or tokens', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: `/${username}/Documents/http-mkdir-${Date.now()}`,
|
||||
auth_token: token,
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const entry = (await response.json()) as Record<string, unknown>;
|
||||
expect(entry.uuid).toEqual(expect.any(String));
|
||||
expect(entry.isDir).toBe(true);
|
||||
for (const field of [
|
||||
'id',
|
||||
'parentId',
|
||||
'userId',
|
||||
'associatedAppId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(entry).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('startBatchWrite does not expose storage internals', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(
|
||||
new URL('/fs/startBatchWrite', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
// Array body, so there is no `auth_token` field for the auth
|
||||
// probe to read — authenticate via the header instead.
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-signed-${Date.now()}.bin`,
|
||||
size: 4,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const [target] = (await response.json()) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
// What a client actually needs to upload.
|
||||
expect(target!.sessionId).toEqual(expect.any(String));
|
||||
expect(typeof target!.url).toBe('string');
|
||||
// What it does not: where the bytes physically live.
|
||||
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
||||
expect(target).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('still serves the POST form for existing callers', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(new URL('/fs/readdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: `/${username}`, auth_token: token }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as Array<{ name: string }>;
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
});
|
||||
});
|
||||
@@ -3,24 +3,25 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -28,9 +29,9 @@ import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
SignedWriteRequest,
|
||||
SignedWriteResponse,
|
||||
} from './requestTypes.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
@@ -91,11 +92,15 @@ const makeReq = <B>(init: {
|
||||
headers?: Record<string, string>;
|
||||
actor: Actor;
|
||||
user?: { id: number; username: string };
|
||||
method?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? ({} as B),
|
||||
query: init.query ?? {},
|
||||
headers: init.headers ?? {},
|
||||
// Handlers that serve both verbs (readdir) read params from `query` on
|
||||
// GET and `body` otherwise.
|
||||
...(init.method ? { method: init.method } : {}),
|
||||
actor: init.actor,
|
||||
// Some controller helpers fall back to `req.user` (set by the
|
||||
// session middleware) for id / username before reading `req.actor`.
|
||||
@@ -165,13 +170,16 @@ describe('FSController.startBatchWrites', () => {
|
||||
const req = makeReq<SignedWriteRequest[]>({ body, actor });
|
||||
await withActor(actor, () => controller.startBatchWrites(req, res));
|
||||
|
||||
const responses = captured.body as SignedWriteResponse[];
|
||||
const responses = captured.body as ClientSignedWriteResponse[];
|
||||
expect(responses).toHaveLength(2);
|
||||
for (const r of responses) {
|
||||
expect(r.sessionId).toEqual(expect.any(String));
|
||||
expect(r.objectKey).toEqual(expect.any(String));
|
||||
expect(r.bucket).toEqual(expect.any(String));
|
||||
expect(r.uploadMode).toBe('single');
|
||||
// Storage internals stay server-side: the presigned `url` already
|
||||
// encodes bucket and key.
|
||||
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
||||
expect(r).not.toHaveProperty(field);
|
||||
}
|
||||
// In-memory mock S3 still returns a presigned-URL string for
|
||||
// single-mode uploads — verify it's there but don't assert
|
||||
// shape (varies by region/host config).
|
||||
@@ -203,7 +211,7 @@ describe('FSController.startBatchWrites', () => {
|
||||
const { res, captured } = makeRes();
|
||||
const req = makeReq<SignedWriteRequest[]>({ body, actor });
|
||||
await withActor(actor, () => controller.startBatchWrites(req, res));
|
||||
const [response] = captured.body as SignedWriteResponse[];
|
||||
const [response] = captured.body as ClientSignedWriteResponse[];
|
||||
const session = await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
response!.sessionId,
|
||||
);
|
||||
@@ -329,7 +337,8 @@ describe('FSController.completeBatchWrites', () => {
|
||||
startRes.res,
|
||||
),
|
||||
);
|
||||
const startResponses = startRes.captured.body as SignedWriteResponse[];
|
||||
const startResponses = startRes.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
expect(startResponses).toHaveLength(2);
|
||||
|
||||
// 2) Complete via the controller. Single-mode completion only
|
||||
@@ -352,16 +361,29 @@ describe('FSController.completeBatchWrites', () => {
|
||||
const responses = captured.body as Array<{
|
||||
sessionId: string;
|
||||
wasOverwrite: boolean;
|
||||
fsEntry: { path: string; userId: number; isDir: boolean };
|
||||
fsEntry: Record<string, unknown>;
|
||||
}>;
|
||||
expect(responses.map((r) => r.fsEntry.path).sort()).toEqual([
|
||||
expect(responses.map((r) => r.fsEntry.path as string).sort()).toEqual([
|
||||
`/${username}/Documents/c.txt`,
|
||||
`/${username}/Documents/d.txt`,
|
||||
]);
|
||||
for (const response of responses) {
|
||||
expect(response.wasOverwrite).toBe(false);
|
||||
expect(response.fsEntry.userId).toBe(userId);
|
||||
expect(response.fsEntry.isDir).toBe(false);
|
||||
// Ownership is asserted against the store below — the response
|
||||
// itself must not carry the owner, storage columns, or the
|
||||
// capability tokens.
|
||||
for (const field of [
|
||||
'userId',
|
||||
'id',
|
||||
'parentId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(response.fsEntry).not.toHaveProperty(field);
|
||||
}
|
||||
}
|
||||
|
||||
// The real fsentries were committed and are now resolvable.
|
||||
@@ -392,7 +414,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
),
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
.body as ClientSignedWriteResponse[];
|
||||
const firstComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
@@ -424,7 +446,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
),
|
||||
);
|
||||
const [secondResponse] = secondStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
.body as ClientSignedWriteResponse[];
|
||||
|
||||
const secondComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -464,20 +486,25 @@ describe('FSController.completeBatchWrites', () => {
|
||||
start.res,
|
||||
),
|
||||
);
|
||||
const [started] = start.captured.body as SignedWriteResponse[];
|
||||
const [started] = start.captured.body as ClientSignedWriteResponse[];
|
||||
|
||||
// Actually upload 4096 bytes to the session's object key (simulating
|
||||
// a client that PUTs more than it declared via the signed URL).
|
||||
// a client that PUTs more than it declared via the signed URL). The
|
||||
// storage location isn't in the response any more, so read it off the
|
||||
// upload session — a real client just PUTs to the presigned URL.
|
||||
const session = await server.stores.fsEntry.getPendingEntryBySessionId(
|
||||
started!.sessionId,
|
||||
);
|
||||
const realBytes = Buffer.alloc(4096, 0x41);
|
||||
await server.stores.s3Object.uploadFromServer(
|
||||
{
|
||||
bucket: started!.bucket,
|
||||
objectKey: started!.objectKey,
|
||||
bucket: session!.bucket!,
|
||||
objectKey: session!.objectKey,
|
||||
contentType: 'application/octet-stream',
|
||||
body: realBytes,
|
||||
contentLength: realBytes.byteLength,
|
||||
},
|
||||
started!.bucketRegion,
|
||||
session!.bucketRegion!,
|
||||
);
|
||||
|
||||
const complete = makeRes();
|
||||
@@ -518,7 +545,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
),
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
@@ -555,7 +582,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
),
|
||||
);
|
||||
const [secondResponse] = secondStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
.body as ClientSignedWriteResponse[];
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let updatedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||||
@@ -635,7 +662,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
startA.res,
|
||||
),
|
||||
);
|
||||
const [aResponse] = startA.captured.body as SignedWriteResponse[];
|
||||
const [aResponse] = startA.captured.body as ClientSignedWriteResponse[];
|
||||
|
||||
const err = await withActor(b.actor, () =>
|
||||
controller
|
||||
@@ -696,6 +723,41 @@ describe('FSController.statEntry', () => {
|
||||
expect(body.name).toBe('stat-me');
|
||||
});
|
||||
|
||||
it('does not leak backend-internal fields to the client', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/no-leak` },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.statEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/no-leak` },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
for (const field of [
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'userId',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(body).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the subtree size when return_size is set on a directory', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
@@ -813,7 +875,11 @@ describe('FSController.readdirEntries', () => {
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
// Matches the legacy `/readdir` code the SDK moved off of.
|
||||
legacyCode: 'dest_is_not_a_directory',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1510,6 +1576,546 @@ describe('FSController.readdirEntries sort + limit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- /readdir pagination envelope --
|
||||
|
||||
describe('FSController.readdirEntries pagination', () => {
|
||||
const makeDocs = async (names: string[]) => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
for (const name of names) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${name}` },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { actor, path: `/${username}/Documents` };
|
||||
};
|
||||
|
||||
const readdir = async (
|
||||
actor: Awaited<ReturnType<typeof makeUser>>['actor'],
|
||||
body: Record<string, unknown>,
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirEntries(makeReq({ body, actor }), res),
|
||||
);
|
||||
return captured.body;
|
||||
};
|
||||
|
||||
it('keeps the bare array response for limit/offset requests', async () => {
|
||||
const { actor, path } = await makeDocs(['a', 'b', 'c']);
|
||||
const body = await readdir(actor, { path, limit: 2, offset: 1 });
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect((body as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the envelope when cursor is present (null = first page)', async () => {
|
||||
const { actor, path } = await makeDocs(['a', 'b', 'c']);
|
||||
const page = (await readdir(actor, { path, cursor: null })) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
};
|
||||
expect(page.items.map((e) => e.name)).toEqual(['a', 'b', 'c']);
|
||||
expect(page.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages through children with cursors in sort order', async () => {
|
||||
const { actor, path } = await makeDocs(['d1', 'd2', 'd3', 'd4', 'd5']);
|
||||
const names: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await readdir(actor, {
|
||||
path,
|
||||
limit: 2,
|
||||
cursor,
|
||||
})) as { items: Array<{ name: string }>; cursor?: string };
|
||||
names.push(...page.items.map((e) => e.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(names).toEqual(['d1', 'd2', 'd3', 'd4', 'd5']);
|
||||
});
|
||||
|
||||
it('respects descending sort across pages', async () => {
|
||||
const { actor, path } = await makeDocs(['a', 'b', 'c', 'd']);
|
||||
const first = (await readdir(actor, {
|
||||
path,
|
||||
limit: 2,
|
||||
cursor: null,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
})) as { items: Array<{ name: string }>; cursor?: string };
|
||||
expect(first.items.map((e) => e.name)).toEqual(['d', 'c']);
|
||||
const second = (await readdir(actor, {
|
||||
path,
|
||||
limit: 2,
|
||||
cursor: first.cursor,
|
||||
})) as { items: Array<{ name: string }>; cursor?: string };
|
||||
expect(second.items.map((e) => e.name)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('rejects a cursor that conflicts with the requested sort', async () => {
|
||||
const { actor, path } = await makeDocs(['a', 'b', 'c']);
|
||||
const first = (await readdir(actor, {
|
||||
path,
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
sortBy: 'name',
|
||||
})) as { cursor?: string };
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.readdirEntries(
|
||||
makeReq({
|
||||
body: {
|
||||
path,
|
||||
limit: 1,
|
||||
cursor: first.cursor,
|
||||
sortBy: 'size',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('reports total with includeTotal', async () => {
|
||||
const { actor, path } = await makeDocs(['a', 'b', 'c']);
|
||||
const page = (await readdir(actor, {
|
||||
path,
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
includeTotal: true,
|
||||
})) as { items: unknown[]; total?: number };
|
||||
expect(page.items.length).toBe(1);
|
||||
expect(page.total).toBe(3);
|
||||
});
|
||||
|
||||
it('wraps the root listing in an envelope when asked', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const page = (await readdir(actor, {
|
||||
path: '/',
|
||||
cursor: null,
|
||||
includeTotal: true,
|
||||
})) as { items: unknown[]; total?: number; cursor?: string };
|
||||
expect(Array.isArray(page.items)).toBe(true);
|
||||
expect(page.total).toBe(page.items.length);
|
||||
expect(page.cursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -- /readdir recursive (nested listing) --
|
||||
|
||||
describe('FSController.readdirEntries recursive', () => {
|
||||
// Seed a nested tree under Documents/tree and return its base path.
|
||||
// Relative depths: l1a/l1b = 1, l2a = 2, l3a = 3, l4a = 4.
|
||||
const makeTree = async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const base = `/${username}/Documents/tree`;
|
||||
const dirs = [
|
||||
base,
|
||||
`${base}/l1a`,
|
||||
`${base}/l1b`,
|
||||
`${base}/l1a/l2a`,
|
||||
`${base}/l1a/l2a/l3a`,
|
||||
`${base}/l1a/l2a/l3a/l4a`,
|
||||
];
|
||||
for (const path of dirs) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { actor, userId, base };
|
||||
};
|
||||
|
||||
const readdir = async (
|
||||
actor: Awaited<ReturnType<typeof makeUser>>['actor'],
|
||||
body: Record<string, unknown>,
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirEntries(makeReq({ body, actor }), res),
|
||||
);
|
||||
return captured.body;
|
||||
};
|
||||
|
||||
const rel = (base: string, items: Array<{ path: string }>) =>
|
||||
items.map((e) => e.path.slice(base.length + 1)).sort();
|
||||
|
||||
it('depth 1 returns only direct children (like a normal readdir)', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 1,
|
||||
})) as { items: Array<{ path: string }>; cursor?: string };
|
||||
expect(rel(base, page.items)).toEqual(['l1a', 'l1b']);
|
||||
});
|
||||
|
||||
it('deeper levels appear as depth grows', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const d2 = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 2,
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(rel(base, d2.items)).toEqual(['l1a', 'l1a/l2a', 'l1b']);
|
||||
|
||||
const d3 = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 3,
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(rel(base, d3.items)).toEqual([
|
||||
'l1a',
|
||||
'l1a/l2a',
|
||||
'l1a/l2a/l3a',
|
||||
'l1b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps depth at 10 so a huge depth returns the whole subtree', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 9999,
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(rel(base, page.items)).toEqual([
|
||||
'l1a',
|
||||
'l1a/l2a',
|
||||
'l1a/l2a/l3a',
|
||||
'l1a/l2a/l3a/l4a',
|
||||
'l1b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pages through the whole subtree with cursors, no dupes or gaps', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
limit: 2,
|
||||
cursor,
|
||||
})) as { items: Array<{ path: string }>; cursor?: string };
|
||||
expect(page.items.length).toBeLessThanOrEqual(2);
|
||||
seen.push(...page.items.map((e) => e.path));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(
|
||||
rel(
|
||||
base,
|
||||
seen.map((path) => ({ path })),
|
||||
),
|
||||
).toEqual(['l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b']);
|
||||
});
|
||||
|
||||
it('counts the subtree with includeTotal', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 2,
|
||||
cursor: null,
|
||||
includeTotal: true,
|
||||
})) as { items: unknown[]; total?: number };
|
||||
expect(page.total).toBe(3); // l1a, l1b, l2a
|
||||
});
|
||||
|
||||
it('enriches entries with type, thumbnail and associatedApp', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 1,
|
||||
})) as {
|
||||
items: Array<{
|
||||
type?: unknown;
|
||||
thumbnail?: unknown;
|
||||
associatedApp?: unknown;
|
||||
}>;
|
||||
};
|
||||
for (const item of page.items) {
|
||||
expect(item.type).toBe('folder');
|
||||
expect(item.thumbnail ?? null).toBeNull();
|
||||
expect('associatedApp' in item).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives files a MIME type and an associatedApp field', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const dir = `/${username}/Documents/files`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path: dir }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
await withActor(actor, () =>
|
||||
controller.touchEntry(
|
||||
makeReq({ body: { path: `${dir}/pic.png` }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const page = (await readdir(actor, {
|
||||
path: dir,
|
||||
recursive: true,
|
||||
depth: 1,
|
||||
})) as {
|
||||
items: Array<{
|
||||
name: string;
|
||||
type?: unknown;
|
||||
associatedApp?: unknown;
|
||||
}>;
|
||||
};
|
||||
const file = page.items.find((e) => e.name === 'pic.png')!;
|
||||
expect(String(file.type)).toContain('image/png');
|
||||
expect('associatedApp' in file).toBe(true);
|
||||
});
|
||||
|
||||
it('masks denials for app-under-user actors as a 404 (legacy parity)', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appActor: Actor = {
|
||||
...userActor,
|
||||
app: { uid: `app-readdir-${uuidv4()}` },
|
||||
};
|
||||
// The user's Documents is outside the app's AppData subtree, so the
|
||||
// app can't list it. Legacy `/readdir` masks this as a 404
|
||||
// subject_does_not_exist rather than leaking a 403.
|
||||
await expect(
|
||||
withActor(appActor, () =>
|
||||
controller.readdirEntries(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents` },
|
||||
actor: appActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects recursive listing at the root', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.readdirEntries(
|
||||
makeReq({
|
||||
body: { path: '/', recursive: true },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('does not leak another users identically-named subtree', async () => {
|
||||
const { actor, base } = await makeTree();
|
||||
// A second user with the same relative tree must not appear.
|
||||
await makeTree();
|
||||
const page = (await readdir(actor, {
|
||||
path: base,
|
||||
recursive: true,
|
||||
depth: 10,
|
||||
})) as { items: Array<{ path: string }> };
|
||||
for (const item of page.items) {
|
||||
expect(item.path.startsWith(`${base}/`)).toBe(true);
|
||||
}
|
||||
expect(page.items).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
// -- /readdir over GET (query params) --
|
||||
|
||||
describe('FSController.readdirEntriesViaGet', () => {
|
||||
const seed = async (names: string[]) => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const dir = `/${username}/Documents/get-readdir`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path: dir }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
for (const name of names) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path: `${dir}/${name}` }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { actor, dir };
|
||||
};
|
||||
|
||||
// Query strings carry every value as a string — that is the whole risk
|
||||
// surface of this route, so tests pass strings the way a real URL would.
|
||||
const getReaddir = async (
|
||||
actor: Awaited<ReturnType<typeof makeUser>>['actor'],
|
||||
query: Record<string, unknown>,
|
||||
) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirEntriesViaGet(
|
||||
makeReq({ query, actor, method: 'GET' }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
return captured.body;
|
||||
};
|
||||
|
||||
it('lists a directory from query params', async () => {
|
||||
const { actor, dir } = await seed(['a', 'b', 'c']);
|
||||
const body = await getReaddir(actor, { path: dir });
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect((body as Array<{ name: string }>).map((e) => e.name)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors string limit/offset like the POST form', async () => {
|
||||
const { actor, dir } = await seed(['a', 'b', 'c']);
|
||||
const body = (await getReaddir(actor, {
|
||||
path: dir,
|
||||
limit: '2',
|
||||
offset: '1',
|
||||
})) as Array<{ name: string }>;
|
||||
expect(body.map((e) => e.name)).toEqual(['b', 'c']);
|
||||
});
|
||||
|
||||
it('treats an empty cursor as the first page and returns the envelope', async () => {
|
||||
const { actor, dir } = await seed(['a', 'b', 'c']);
|
||||
const page = (await getReaddir(actor, { path: dir, cursor: '' })) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
};
|
||||
expect(page.items.map((e) => e.name)).toEqual(['a', 'b', 'c']);
|
||||
expect(page.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages through with a string cursor', async () => {
|
||||
const { actor, dir } = await seed(['d1', 'd2', 'd3', 'd4', 'd5']);
|
||||
const names: string[] = [];
|
||||
let cursor: string | undefined = '';
|
||||
do {
|
||||
const page = (await getReaddir(actor, {
|
||||
path: dir,
|
||||
limit: '2',
|
||||
cursor,
|
||||
})) as { items: Array<{ name: string }>; cursor?: string };
|
||||
names.push(...page.items.map((e) => e.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(names).toEqual(['d1', 'd2', 'd3', 'd4', 'd5']);
|
||||
});
|
||||
|
||||
it('coerces string includeTotal=true', async () => {
|
||||
const { actor, dir } = await seed(['a', 'b', 'c']);
|
||||
const page = (await getReaddir(actor, {
|
||||
path: dir,
|
||||
limit: '1',
|
||||
cursor: '',
|
||||
includeTotal: 'true',
|
||||
})) as { items: unknown[]; total?: number };
|
||||
expect(page.items.length).toBe(1);
|
||||
expect(page.total).toBe(3);
|
||||
});
|
||||
|
||||
it('coerces string recursive/depth and lists nested entries', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const base = `/${username}/Documents/get-tree`;
|
||||
for (const path of [base, `${base}/a`, `${base}/a/b`]) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({ body: { path }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
const page = (await getReaddir(actor, {
|
||||
path: base,
|
||||
recursive: 'true',
|
||||
depth: '2',
|
||||
})) as { items: Array<{ path: string }> };
|
||||
expect(
|
||||
page.items.map((e) => e.path.slice(base.length + 1)).sort(),
|
||||
).toEqual(['a', 'a/b']);
|
||||
});
|
||||
|
||||
it('rejects a non-directory target with the legacy code', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const file = `/${username}/Documents/get-file.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touchEntry(
|
||||
makeReq({ body: { path: file }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.readdirEntriesViaGet(
|
||||
makeReq({ query: { path: file }, actor, method: 'GET' }),
|
||||
makeRes().res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'dest_is_not_a_directory',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose internal ids, storage columns or tokens', async () => {
|
||||
const { actor, dir } = await seed(['a']);
|
||||
const body = (await getReaddir(actor, { path: dir })) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const entry = body[0]!;
|
||||
// Numeric primary keys and storage/token columns must never ship in a
|
||||
// listing — entries are addressed by uuid.
|
||||
for (const field of [
|
||||
'id',
|
||||
'parentId',
|
||||
'userId',
|
||||
'associatedAppId',
|
||||
'bucket',
|
||||
'bucketRegion',
|
||||
'publicToken',
|
||||
'fileRequestToken',
|
||||
]) {
|
||||
expect(entry).not.toHaveProperty(field);
|
||||
}
|
||||
expect(entry.uuid).toEqual(expect.any(String));
|
||||
// No user-identifying data anywhere in the serialized payload.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('@');
|
||||
expect(serialized).not.toMatch(/"(email|owner|user_id|userId)"/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /touch additional branches ──────────────────────────────────────
|
||||
|
||||
describe('FSController.touchEntry additional branches', () => {
|
||||
@@ -2022,10 +2628,9 @@ describe('FSController associatedAppId entitlement gate', () => {
|
||||
],
|
||||
);
|
||||
const row = (
|
||||
await server.clients.db.read(
|
||||
'SELECT id FROM apps WHERE uid = ?',
|
||||
[uid],
|
||||
)
|
||||
await server.clients.db.read('SELECT id FROM apps WHERE uid = ?', [
|
||||
uid,
|
||||
])
|
||||
)[0] as { id: number };
|
||||
return { id: row.id };
|
||||
};
|
||||
@@ -2041,13 +2646,15 @@ describe('FSController associatedAppId entitlement gate', () => {
|
||||
await withActor(actor, () =>
|
||||
controller.startBatchWrites(
|
||||
makeReq<SignedWriteRequest[]>({
|
||||
body: [{ fileMetadata: { path, size: 3, associatedAppId } }],
|
||||
body: [
|
||||
{ fileMetadata: { path, size: 3, associatedAppId } },
|
||||
],
|
||||
actor,
|
||||
}),
|
||||
startRes.res,
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as SignedWriteResponse[];
|
||||
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import Busboy from 'busboy';
|
||||
@@ -40,13 +41,24 @@ import {
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { FS_COSTS } from './costs.js';
|
||||
import {
|
||||
assertAccess as assertLegacyAccess,
|
||||
fsEntryMimeType,
|
||||
loadLegacyAssociatedApps,
|
||||
signEntryThumbnail,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import type {
|
||||
ClientCompleteWriteResponse,
|
||||
ClientFSEntry,
|
||||
ClientReaddirEntry,
|
||||
ClientSignedWriteResponse,
|
||||
ClientSignMultipartPartsResponse,
|
||||
ClientWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
CompleteWriteResponse,
|
||||
SignedWriteRequest,
|
||||
SignedWriteResponse,
|
||||
SignMultipartPartsRequest,
|
||||
SignMultipartPartsResponse,
|
||||
WriteGuiMetadata,
|
||||
WriteRequest,
|
||||
WriteResponse,
|
||||
@@ -60,7 +72,6 @@ import type {
|
||||
ThumbnailUploadPrepareItem,
|
||||
ThumbnailUploadPreparePayload,
|
||||
} from './types.js';
|
||||
import { toLegacyEntry } from './legacyFsHelpers.js';
|
||||
class UploadProgressTracker implements UploadProgressTrackerLike {
|
||||
total = 0;
|
||||
progress = 0;
|
||||
@@ -89,6 +100,8 @@ class UploadProgressTracker implements UploadProgressTrackerLike {
|
||||
}
|
||||
|
||||
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
||||
// Hard cap on how many levels below the target a recursive readdir descends.
|
||||
const MAX_READDIR_DEPTH = 10;
|
||||
const DEFAULT_BATCH_ACL_CHECK_CONCURRENCY = 32;
|
||||
const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
|
||||
|
||||
@@ -106,7 +119,7 @@ export class FSController extends PuterController {
|
||||
@Post('/startWrite', { subdomain: 'api', requireVerified: true })
|
||||
async startWrite(
|
||||
req: Request<RouteParams, null, SignedWriteRequest>,
|
||||
res: Response<SignedWriteResponse>,
|
||||
res: Response<ClientSignedWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -156,13 +169,15 @@ export class FSController extends PuterController {
|
||||
}
|
||||
}, 'emitStartWriteDirectoryEvents');
|
||||
}
|
||||
res.json(response);
|
||||
res.json(
|
||||
this.#withoutStorageInternals(this.#withClientFsEntry(response)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/startBatchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async startBatchWrites(
|
||||
req: Request<RouteParams, null, SignedWriteRequest[]>,
|
||||
res: Response<SignedWriteResponse[]>,
|
||||
res: Response<ClientSignedWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -252,13 +267,17 @@ export class FSController extends PuterController {
|
||||
}
|
||||
}, 'emitStartBatchWriteDirectoryEvents');
|
||||
}
|
||||
res.json(responses);
|
||||
res.json(
|
||||
responses.map((r) =>
|
||||
this.#withoutStorageInternals(this.#withClientFsEntry(r)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/completeWrite', { subdomain: 'api', requireVerified: true })
|
||||
async completeWrite(
|
||||
req: Request<RouteParams, null, CompleteWriteRequest>,
|
||||
res: Response<CompleteWriteResponse>,
|
||||
res: Response<ClientCompleteWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requestBody = this.#withGuiMetadata(req.body, req.body);
|
||||
@@ -278,13 +297,18 @@ export class FSController extends PuterController {
|
||||
},
|
||||
requestBody.guiMetadata,
|
||||
);
|
||||
res.json({ ...response, fsEntry: writeResponse.fsEntry });
|
||||
res.json(
|
||||
this.#withRequiredClientFsEntry({
|
||||
...response,
|
||||
fsEntry: writeResponse.fsEntry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/completeBatchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async completeBatchWrites(
|
||||
req: Request<RouteParams, null, CompleteWriteRequest[]>,
|
||||
res: Response<CompleteWriteResponse[]>,
|
||||
res: Response<ClientCompleteWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requests = Array.isArray(req.body)
|
||||
@@ -319,7 +343,9 @@ export class FSController extends PuterController {
|
||||
return { ...writeResponse, fsEntry: withSideEffects.fsEntry };
|
||||
},
|
||||
);
|
||||
res.json(updatedResponse);
|
||||
res.json(
|
||||
updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('/abortWrite', { subdomain: 'api', requireVerified: true })
|
||||
@@ -341,20 +367,20 @@ export class FSController extends PuterController {
|
||||
@Post('/signMultipartParts', { subdomain: 'api', requireVerified: true })
|
||||
async signMultipartParts(
|
||||
req: Request<RouteParams, null, SignMultipartPartsRequest>,
|
||||
res: Response<SignMultipartPartsResponse>,
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const response = await this.services.fs.signMultipartParts(
|
||||
userId,
|
||||
req.body,
|
||||
);
|
||||
res.json(response);
|
||||
res.json(this.#withoutStorageInternals(response));
|
||||
}
|
||||
|
||||
@Post('/write', { subdomain: 'api', requireVerified: true })
|
||||
async write(
|
||||
req: Request<RouteParams, null, WriteRequest>,
|
||||
res: Response<WriteResponse>,
|
||||
res: Response<ClientWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -394,13 +420,13 @@ export class FSController extends PuterController {
|
||||
response,
|
||||
requestBody.guiMetadata,
|
||||
);
|
||||
res.json(updatedResponse);
|
||||
res.json(this.#withRequiredClientFsEntry(updatedResponse));
|
||||
}
|
||||
|
||||
@Post('/batchWrite', { subdomain: 'api', requireVerified: true })
|
||||
async batchWrites(
|
||||
req: Request<RouteParams, null, WriteRequest[]>,
|
||||
res: Response<WriteResponse[]>,
|
||||
res: Response<ClientWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
@@ -702,7 +728,9 @@ export class FSController extends PuterController {
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(updatedResponses);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -822,7 +850,9 @@ export class FSController extends PuterController {
|
||||
);
|
||||
},
|
||||
);
|
||||
res.json(updatedResponses);
|
||||
res.json(
|
||||
updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Read-side routes ------------------------------------------------
|
||||
@@ -845,17 +875,135 @@ export class FSController extends PuterController {
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
|
||||
res.json({
|
||||
...entry,
|
||||
...this.#toClientEntry(entry),
|
||||
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip backend-internal fields before returning an entry to a client.
|
||||
* Storage location (bucket/region), the owner's numeric id, and the
|
||||
* capability-token columns are never used by clients and must not leak to
|
||||
* callers who only hold `see`/`list` on the entry — a share recipient, or
|
||||
* (with public folders enabled) any authenticated user. The legacy read
|
||||
* path already curates its output; this does the same for the v2 routes.
|
||||
*/
|
||||
#toClientEntry(entry: FSEntry): ClientFSEntry {
|
||||
// Allowlist, not a denylist: a denylist silently ships every column
|
||||
// added to `fsentries` later. Omits the numeric primary keys (`id`,
|
||||
// `parentId`, `associatedAppId`), the storage columns, the owning
|
||||
// `userId`, and the `publicToken`/`fileRequestToken` capability tokens.
|
||||
//
|
||||
// Tolerant of partially-hydrated entries: write/mkdir paths return a
|
||||
// freshly-built entry that hasn't been through a subdomain join.
|
||||
const subdomains = entry.subdomains ?? [];
|
||||
return {
|
||||
uuid: entry.uuid,
|
||||
uid: entry.uid ?? entry.uuid,
|
||||
parentUid: entry.parentUid ?? null,
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
isDir: entry.isDir,
|
||||
isShortcut: entry.isShortcut,
|
||||
shortcutTo: entry.shortcutTo ?? null,
|
||||
isSymlink: entry.isSymlink,
|
||||
symlinkPath: entry.symlinkPath ?? null,
|
||||
isPublic: entry.isPublic ?? null,
|
||||
immutable: entry.immutable,
|
||||
metadata: entry.metadata ?? null,
|
||||
modified: entry.modified,
|
||||
created: entry.created ?? null,
|
||||
accessed: entry.accessed ?? null,
|
||||
size: entry.size ?? null,
|
||||
layout: entry.layout ?? null,
|
||||
subdomains,
|
||||
workers: entry.workers ?? [],
|
||||
hasWebsite: entry.hasWebsite ?? subdomains.length > 0,
|
||||
suggestedApps: entry.suggestedApps ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the `fsEntry` a write response carries, leaving the rest of the
|
||||
* envelope (session id, presigned upload targets) untouched — those fields
|
||||
* are the point of the response. Applied at every `res.json` on the write
|
||||
* paths, which previously serialized the raw database row.
|
||||
*
|
||||
* The return type is the sanitized counterpart, so a caller cannot keep
|
||||
* treating the result as though it still held a full `FSEntry`.
|
||||
*/
|
||||
#withClientFsEntry<T extends { fsEntry?: FSEntry }>(
|
||||
response: T,
|
||||
): Omit<T, 'fsEntry'> & { fsEntry?: ClientFSEntry } {
|
||||
const { fsEntry, ...rest } = response;
|
||||
return {
|
||||
...rest,
|
||||
...(fsEntry ? { fsEntry: this.#toClientEntry(fsEntry) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the storage internals from a presigned-upload envelope. The client
|
||||
* uploads to the presigned URLs, which already encode bucket and key, so
|
||||
* naming the physical location of a user's bytes buys the caller nothing.
|
||||
*/
|
||||
#withoutStorageInternals<
|
||||
T extends { bucket: string; bucketRegion: string; objectKey: string },
|
||||
>(response: T): Omit<T, 'bucket' | 'bucketRegion' | 'objectKey'> {
|
||||
const { bucket, bucketRegion, objectKey, ...rest } = response;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Same, for the responses whose `fsEntry` is always present. */
|
||||
#withRequiredClientFsEntry<T extends { fsEntry: FSEntry }>(
|
||||
response: T,
|
||||
): Omit<T, 'fsEntry'> & { fsEntry: ClientFSEntry } {
|
||||
return {
|
||||
...response,
|
||||
fsEntry: this.#toClientEntry(response.fsEntry),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /fs/readdir` — same contract as the POST form, with parameters in
|
||||
* the query string instead of the body. A read as a GET is cacheable and
|
||||
* can authenticate via `?auth_token=`, which lets callers fetch a listing
|
||||
* without a JSON body. Every value arrives as a string, so parameter
|
||||
* parsing goes through the same coercion helpers the POST path uses.
|
||||
*/
|
||||
@Get('/readdir', { subdomain: 'api', requireVerified: true })
|
||||
async readdirEntriesViaGet(req: Request, res: Response) {
|
||||
return this.readdirEntries(req, res);
|
||||
}
|
||||
|
||||
@Post('/readdir', { subdomain: 'api', requireVerified: true })
|
||||
async readdirEntries(req: Request, res: Response) {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
// GET carries its parameters in the query string; POST in the body.
|
||||
const body = this.#toObjectRecord(
|
||||
req.method === 'GET' ? req.query : req.body,
|
||||
);
|
||||
|
||||
// Presence of `cursor` (null/empty means "first page") or
|
||||
// `includeTotal` opts into the paginated `{items, cursor?, total?}`
|
||||
// envelope. Legacy limit/offset requests keep the bare-array response.
|
||||
const includeTotal = this.#toBoolean(body.includeTotal) === true;
|
||||
const paginated =
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
includeTotal;
|
||||
|
||||
// Undocumented: `recursive` lists descendants (prefix scan) up to
|
||||
// `depth` levels below the target. Always paginated; sorts by path.
|
||||
const recursive = this.#toBoolean(body.recursive) === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
if (recursive) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'recursive listing is not supported at the root',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const { listRootEntries } =
|
||||
await import('../../services/fs/rootListing.js');
|
||||
const rootChildren = await listRootEntries(
|
||||
@@ -873,54 +1021,155 @@ export class FSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
res.json(rootChildren);
|
||||
const rootItems = await this.#toReaddirEntries(rootChildren);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: rootItems,
|
||||
...(includeTotal ? { total: rootItems.length } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(rootItems);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = await this.#resolveEntryForRequest(body);
|
||||
if (!parent.isDir) {
|
||||
throw new HttpError(400, 'Target is not a directory', {
|
||||
legacyCode: 'bad_request',
|
||||
legacyCode: 'dest_is_not_a_directory',
|
||||
});
|
||||
}
|
||||
await this.#assertAccess(actor, parent.path, 'list');
|
||||
// Use the legacy access assertion so this endpoint stays behaviorally
|
||||
// identical to the `/readdir` route the SDK moved off of — same error
|
||||
// codes and the same app-actor 404 masking on denial.
|
||||
await assertLegacyAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
parent.path,
|
||||
'list',
|
||||
);
|
||||
|
||||
const limit = this.#toNumberOrUndefined(body.limit);
|
||||
const offset = this.#toNumberOrUndefined(body.offset);
|
||||
const sortByRaw =
|
||||
typeof body.sort_by === 'string'
|
||||
? body.sort_by.toLowerCase()
|
||||
typeof (body.sortBy ?? body.sort_by) === 'string'
|
||||
? String(body.sortBy ?? body.sort_by).toLowerCase()
|
||||
: undefined;
|
||||
const sortBy =
|
||||
(['name', 'modified', 'type', 'size'] as const).find(
|
||||
(v) => v === sortByRaw,
|
||||
) ?? null;
|
||||
const sortOrderRaw =
|
||||
typeof body.sort_order === 'string'
|
||||
? body.sort_order.toLowerCase()
|
||||
typeof (body.sortOrder ?? body.sort_order) === 'string'
|
||||
? String(body.sortOrder ?? body.sort_order).toLowerCase()
|
||||
: undefined;
|
||||
const sortOrder =
|
||||
(['asc', 'desc'] as const).find((v) => v === sortOrderRaw) ?? null;
|
||||
|
||||
if (recursive) {
|
||||
const requestedDepth = this.#toNumberOrUndefined(body.depth);
|
||||
const maxDepth = Math.min(
|
||||
MAX_READDIR_DEPTH,
|
||||
Math.max(1, Math.floor(requestedDepth ?? MAX_READDIR_DEPTH)),
|
||||
);
|
||||
const page = await this.services.fs.listDirectoryTreePage(
|
||||
parent.userId,
|
||||
parent.path,
|
||||
{
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string'
|
||||
? body.cursor
|
||||
: undefined,
|
||||
maxDepth,
|
||||
},
|
||||
);
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
const total = includeTotal
|
||||
? await this.services.fs.countDirectoryTree(
|
||||
parent.userId,
|
||||
parent.path,
|
||||
maxDepth,
|
||||
)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginated) {
|
||||
const page = await this.services.fs.listDirectoryPage(parent.uuid, {
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string' ? body.cursor : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(page.entries);
|
||||
const total = includeTotal
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(children);
|
||||
res.json(await this.#toReaddirEntries(children));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape readdir entries in the v2 (camelCase) response shape, enriched with
|
||||
* the three fields the SDK cannot reconstruct on its own so it can rebuild
|
||||
* the v1 shape: `type` (MIME), a signed `thumbnail`, and `associatedApp`.
|
||||
*/
|
||||
async #toReaddirEntries(entries: FSEntry[]): Promise<ClientReaddirEntry[]> {
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
entries,
|
||||
);
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...this.#toClientEntry(entry),
|
||||
// Fields the client cannot derive on its own.
|
||||
type: fsEntryMimeType(entry),
|
||||
thumbnail: await signEntryThumbnail(
|
||||
this.clients.event,
|
||||
entry.uuid,
|
||||
entry.thumbnail,
|
||||
),
|
||||
associatedApp:
|
||||
entry.associatedAppId !== null
|
||||
? (appsById.get(entry.associatedAppId) ?? null)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async #attachSuggestedApps(entries: FSEntry[]): Promise<void> {
|
||||
const suggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
children,
|
||||
entries,
|
||||
);
|
||||
for (let index = 0; index < children.length; index++) {
|
||||
const child = children[index];
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
const child = entries[index];
|
||||
if (child) {
|
||||
child.suggestedApps = suggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
res.json(children);
|
||||
}
|
||||
|
||||
@Post('/search', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1068,7 +1317,7 @@ export class FSController extends PuterController {
|
||||
) ?? false,
|
||||
});
|
||||
this.#emitGuiItemAdded(entry);
|
||||
res.json(entry);
|
||||
res.json(this.#toClientEntry(entry));
|
||||
}
|
||||
|
||||
@Post('/touch', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1104,7 +1353,7 @@ export class FSController extends PuterController {
|
||||
createMissingParents:
|
||||
this.#toBoolean(body.create_missing_parents) ?? false,
|
||||
});
|
||||
res.json(entry);
|
||||
res.json(this.#toClientEntry(entry));
|
||||
}
|
||||
|
||||
@Post('/rename', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1122,7 +1371,7 @@ export class FSController extends PuterController {
|
||||
|
||||
const renamed = await this.services.fs.rename(entry, newName);
|
||||
this.#emitGuiItemUpdated(renamed);
|
||||
res.json(renamed);
|
||||
res.json(this.#toClientEntry(renamed));
|
||||
}
|
||||
|
||||
@Post('/delete', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1168,7 +1417,7 @@ export class FSController extends PuterController {
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? false,
|
||||
});
|
||||
this.#emitGuiItemMoved(source, moved);
|
||||
res.json(moved);
|
||||
res.json(this.#toClientEntry(moved));
|
||||
}
|
||||
|
||||
@Post('/copy', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1196,7 +1445,7 @@ export class FSController extends PuterController {
|
||||
this.#toBoolean(body.dedupe_name ?? body.change_name) ?? true,
|
||||
});
|
||||
this.#emitGuiItemAdded(copy);
|
||||
res.json(copy);
|
||||
res.json(this.#toClientEntry(copy));
|
||||
}
|
||||
|
||||
@Post('/mkshortcut', { subdomain: 'api', requireVerified: true })
|
||||
@@ -1225,7 +1474,7 @@ export class FSController extends PuterController {
|
||||
dedupeName: this.#toBoolean(body.dedupe_name) ?? true,
|
||||
});
|
||||
this.#emitGuiItemAdded(shortcut);
|
||||
res.json(shortcut);
|
||||
res.json(this.#toClientEntry(shortcut));
|
||||
}
|
||||
|
||||
// -- Read-side helpers -----------------------------------------------
|
||||
@@ -1280,12 +1529,12 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule
|
||||
* is write on the parent, but we also accept write on the target itself
|
||||
* — this lets an app create its own `/<user>/AppData/<app_uid>` folder
|
||||
* (parent `AppData` is off-limits, but the target is the app's own
|
||||
* subtree per ACLService's short-circuit) and lets recipients of a
|
||||
* direct share on a not-yet-existent path materialize it.
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule is
|
||||
* write on the parent, but we also accept write on the target itself — this
|
||||
* lets an app create its own `/<user>/AppData/<app_uid>` folder (parent
|
||||
* `AppData` is off-limits, but the target is the app's own subtree per
|
||||
* ACLService's short-circuit) and lets recipients of a direct share on a
|
||||
* not-yet-existent path materialize it.
|
||||
*/
|
||||
async #assertCanCreate(actor: Actor, targetPath: string) {
|
||||
const parent = pathPosix.dirname(targetPath);
|
||||
@@ -1763,13 +2012,13 @@ export class FSController extends PuterController {
|
||||
|
||||
/**
|
||||
* Bind `associatedAppId` onto the write input only when the actor is
|
||||
* entitled to reference that app. `associatedAppId` is client-supplied
|
||||
* and never trusted for authz, but it's echoed back in legacy FS
|
||||
* responses — so an attacker could plant another tenant's private app id
|
||||
* to confirm the row exists (an enumeration oracle) and harvest its
|
||||
* metadata. Allow binding to public apps (their existence isn't secret)
|
||||
* or to apps the actor owns; drop the association otherwise so the file
|
||||
* simply carries no associated app.
|
||||
* entitled to reference that app. `associatedAppId` is client-supplied and
|
||||
* never trusted for authz, but it's echoed back in legacy FS responses — so
|
||||
* an attacker could plant another tenant's private app id to confirm the
|
||||
* row exists (an enumeration oracle) and harvest its metadata. Allow
|
||||
* binding to public apps (their existence isn't secret) or to apps the
|
||||
* actor owns; drop the association otherwise so the file simply carries no
|
||||
* associated app.
|
||||
*
|
||||
* `#resolveWriteFileMetadata` has already copied the raw client value onto
|
||||
* `fileMetadata`, so a dropped association must be actively stripped — not
|
||||
@@ -1810,8 +2059,7 @@ export class FSController extends PuterController {
|
||||
// the ActorUser type. Access via the escape hatch until a proper
|
||||
// storage-quota mechanism is in place.
|
||||
const actorUser = req.actor?.user as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
|
||||
const candidates = [
|
||||
this.#toStorageCapacityCandidate(actorUser?.free_storage),
|
||||
|
||||
@@ -18,12 +18,15 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
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 { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
|
||||
@@ -688,6 +691,59 @@ describe('LegacyFSController.readdir', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('pages children with cursors and reports totals', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
for (const name of ['p1', 'p2', 'p3']) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${name}` },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const readdir = async (body: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdir(makeReq({ body, actor }), res),
|
||||
);
|
||||
return captured.body;
|
||||
};
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
let total: number | undefined;
|
||||
do {
|
||||
const page = (await readdir({
|
||||
path: `/${username}/Documents`,
|
||||
limit: 2,
|
||||
cursor,
|
||||
includeTotal: true,
|
||||
})) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
seen.push(...page.items.map((e) => e.name));
|
||||
total = page.total;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual(['p1', 'p2', 'p3']);
|
||||
expect(total).toBe(3);
|
||||
|
||||
// Legacy limit-only requests keep the bare array response.
|
||||
const bare = await readdir({
|
||||
path: `/${username}/Documents`,
|
||||
limit: 2,
|
||||
});
|
||||
expect(Array.isArray(bare)).toBe(true);
|
||||
expect((bare as unknown[]).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── copy ────────────────────────────────────────────────────────────
|
||||
@@ -720,13 +776,9 @@ describe('LegacyFSController.copy', () => {
|
||||
copied: { path: string; name: string };
|
||||
}>;
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0].copied.path).toBe(
|
||||
`/${username}/Pictures/src-folder`,
|
||||
);
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/src-folder`);
|
||||
// The original still exists; the copy lives under Pictures.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(src),
|
||||
).not.toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/src-folder`,
|
||||
@@ -761,9 +813,7 @@ describe('LegacyFSController.copy', () => {
|
||||
);
|
||||
|
||||
const body = captured.body as Array<{ copied: { path: string } }>;
|
||||
expect(body[0].copied.path).toBe(
|
||||
`/${username}/Pictures/renamed-copy`,
|
||||
);
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/renamed-copy`);
|
||||
});
|
||||
|
||||
it('copies an empty file (no backing S3 object) without erroring', async () => {
|
||||
@@ -876,9 +926,7 @@ describe('LegacyFSController.copy', () => {
|
||||
expect(Buffer.concat(chunks).byteLength).toBe(0);
|
||||
expect(download.contentLength).toBe(0);
|
||||
// The entry must still exist — the ghost handler must NOT have run.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(src),
|
||||
).not.toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -991,10 +1039,7 @@ describe('LegacyFSController.search', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.search(
|
||||
makeReq({ body: { query: needle }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.search(makeReq({ body: { query: needle }, actor }), res),
|
||||
);
|
||||
const results = captured.body as Array<{ name: string }>;
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
@@ -1046,9 +1091,7 @@ describe('LegacyFSController.search', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
results.some(
|
||||
(r) => r.path === `/${username}/Documents/${needle}`,
|
||||
),
|
||||
results.some((r) => r.path === `/${username}/Documents/${needle}`),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1128,10 +1171,7 @@ describe('LegacyFSController.sign', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.sign(
|
||||
makeReq({ body: { items: [] }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.sign(makeReq({ body: { items: [] }, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1286,6 +1326,113 @@ describe('LegacyFSController.writeFile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── writeFile (write IDOR: authorized subject must equal write target) ─
|
||||
//
|
||||
// Holding a write signature + write ACL on a single file must not let the
|
||||
// caller redirect the upload to an attacker-named sibling via the `name`
|
||||
// field. The write has to land on the signed file itself.
|
||||
|
||||
describe('LegacyFSController.writeFile (write IDOR)', () => {
|
||||
const signingCfgOf = () => {
|
||||
const cfg = (
|
||||
controller as unknown as {
|
||||
config: {
|
||||
api_base_url?: string;
|
||||
url_signature_secret?: string;
|
||||
};
|
||||
}
|
||||
).config;
|
||||
cfg.api_base_url = cfg.api_base_url ?? 'http://api.test.local';
|
||||
cfg.url_signature_secret =
|
||||
cfg.url_signature_secret ?? 'test-signing-secret';
|
||||
return {
|
||||
secret: cfg.url_signature_secret,
|
||||
apiBaseUrl: cfg.api_base_url,
|
||||
};
|
||||
};
|
||||
|
||||
const multipartWriteReq = (opts: {
|
||||
actor: Actor;
|
||||
uid: string;
|
||||
expires: string;
|
||||
signature: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
}): Request => {
|
||||
const boundary = '----lfsWriteIdorBoundary';
|
||||
const payload =
|
||||
`--${boundary}\r\n` +
|
||||
'Content-Disposition: form-data; name="file"; ' +
|
||||
'filename="upload.bin"\r\n' +
|
||||
'Content-Type: application/octet-stream\r\n\r\n' +
|
||||
`${opts.content}\r\n` +
|
||||
`--${boundary}--\r\n`;
|
||||
const req = Readable.from([Buffer.from(payload)]) as unknown as Request;
|
||||
Object.assign(req, {
|
||||
headers: {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
query: {
|
||||
uid: opts.uid,
|
||||
expires: opts.expires,
|
||||
signature: opts.signature,
|
||||
operation: 'write',
|
||||
},
|
||||
body: opts.name !== undefined ? { name: opts.name } : {},
|
||||
actor: opts.actor,
|
||||
});
|
||||
return req;
|
||||
};
|
||||
|
||||
it('writes to the signed file, not an attacker-named sibling', async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const dir = `/${victim.actor.user!.username}/Documents`;
|
||||
const target = `${dir}/secret.txt`;
|
||||
const sibling = `${dir}/passwords.txt`;
|
||||
|
||||
// Victim owns a single file; attacker gets write on just that file.
|
||||
await withActor(victim.actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: target }, actor: victim.actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
expect(entry).toBeTruthy();
|
||||
await server.services.permission.grantUserUserPermission(
|
||||
victim.actor,
|
||||
attacker.actor.user!.username!,
|
||||
`fs:${entry!.uuid}:write`,
|
||||
{},
|
||||
);
|
||||
|
||||
const writeUrl = new URL(signFile(entry!, signingCfgOf()).write_url!);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(attacker.actor, () =>
|
||||
controller.writeFile(
|
||||
multipartWriteReq({
|
||||
actor: attacker.actor,
|
||||
uid: writeUrl.searchParams.get('uid')!,
|
||||
expires: writeUrl.searchParams.get('expires')!,
|
||||
signature: writeUrl.searchParams.get('signature')!,
|
||||
name: 'passwords.txt',
|
||||
content: 'attacker-bytes',
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
// The write landed on the signed file …
|
||||
expect((captured.body as { path?: string }).path).toBe(target);
|
||||
// … and never created the attacker-named sibling.
|
||||
const siblingEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(sibling);
|
||||
expect(siblingEntry).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── file (validation paths) ─────────────────────────────────────────
|
||||
|
||||
describe('LegacyFSController.file', () => {
|
||||
@@ -1433,10 +1580,7 @@ describe('LegacyFSController.requestAppRootDir', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.requestAppRootDir(
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
controller.requestAppRootDir(makeReq({ body: {}, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1612,10 +1756,7 @@ describe('LegacyFSController.down', () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.down(
|
||||
makeReq({ query: { path: '/' }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.down(makeReq({ query: { path: '/' }, actor }), res),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -1672,9 +1813,9 @@ describe('LegacyFSController.mkdir additional branches', () => {
|
||||
}),
|
||||
actor: undefined,
|
||||
} as unknown as Request;
|
||||
await expect(
|
||||
controller.mkdir(req, res),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
await expect(controller.mkdir(req, res)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1694,17 +1835,12 @@ describe('LegacyFSController.delete additional branches', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.delete(
|
||||
makeReq({ body: { path: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.delete(makeReq({ body: { path: target }, actor }), res),
|
||||
);
|
||||
const body = captured.body as { ok: boolean; uid: string };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(typeof body.uid).toBe('string');
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it('forwards descendants_only into fs.remove', async () => {
|
||||
@@ -1835,9 +1971,9 @@ describe('LegacyFSController.df actor gate', () => {
|
||||
...makeReq({ body: {}, actor }),
|
||||
actor: undefined,
|
||||
} as unknown as Request;
|
||||
await expect(
|
||||
controller.df(req, makeRes().res),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
await expect(controller.df(req, makeRes().res)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1854,7 +1990,8 @@ describe('LegacyFSController.down file streaming', () => {
|
||||
ended: false,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Writable } = require('node:stream') as typeof import('node:stream');
|
||||
const { Writable } =
|
||||
require('node:stream') as typeof import('node:stream');
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
captured.bodyChunks.push(chunk);
|
||||
@@ -1900,18 +2037,19 @@ describe('LegacyFSController.down file streaming', () => {
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.down(
|
||||
makeReq({ query: { path: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.down(makeReq({ query: { path: target }, actor }), res),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
// /down forces octet-stream regardless of the entry's true type.
|
||||
expect(captured.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(captured.headers['Content-Type']).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(captured.headers['Content-Disposition']).toMatch(/^attachment;/);
|
||||
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
|
||||
expect(captured.headers['Content-Length']).toBe(
|
||||
String(body.byteLength),
|
||||
);
|
||||
// The piped bytes match the file contents.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
if (captured.bodyChunks.length > 0) {
|
||||
@@ -1958,7 +2096,8 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
bodyChunks: [] as Buffer[],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Writable } = require('node:stream') as typeof import('node:stream');
|
||||
const { Writable } =
|
||||
require('node:stream') as typeof import('node:stream');
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
captured.bodyChunks.push(chunk);
|
||||
@@ -2001,18 +2140,19 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
|
||||
const { res, captured } = makeStreamingRes();
|
||||
await withActor(actor, () =>
|
||||
controller.read(
|
||||
makeReq({ query: { file: target }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.read(makeReq({ query: { file: target }, actor }), res),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
// The v1 contract is octet-stream regardless of real mime; /fs/read
|
||||
// (v2) is the type-aware variant. This is documented in the controller.
|
||||
expect(captured.headers['Content-Type']).toBe('application/octet-stream');
|
||||
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
|
||||
expect(captured.headers['Content-Type']).toBe(
|
||||
'application/octet-stream',
|
||||
);
|
||||
expect(captured.headers['Content-Length']).toBe(
|
||||
String(body.byteLength),
|
||||
);
|
||||
});
|
||||
|
||||
it('honors options.realMime by forwarding the real content-type from mime-types', async () => {
|
||||
@@ -2036,7 +2176,10 @@ describe('LegacyFSController.read file streaming', () => {
|
||||
controller.read(
|
||||
makeReq({ query: { file: target }, actor }),
|
||||
res,
|
||||
{ realMime: true } as never,
|
||||
undefined,
|
||||
{
|
||||
realMime: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
|
||||
@@ -2142,10 +2285,7 @@ describe('LegacyFSController.search fallback fields', () => {
|
||||
);
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.search(
|
||||
makeReq({ body: { text: needle }, actor }),
|
||||
res,
|
||||
),
|
||||
controller.search(makeReq({ body: { text: needle }, actor }), res),
|
||||
);
|
||||
const results = captured.body as Array<{ name: string }>;
|
||||
expect(results.some((r) => r.name === needle)).toBe(true);
|
||||
@@ -2260,7 +2400,9 @@ describe('LegacyFSController.batch additional operations', () => {
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const body = captured.body as { results: Array<Record<string, unknown>> };
|
||||
const body = captured.body as {
|
||||
results: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.results).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -2288,12 +2430,10 @@ describe('LegacyFSController.batch additional operations', () => {
|
||||
),
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(target),
|
||||
).toBeNull();
|
||||
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
|
||||
});
|
||||
|
||||
it("runs a `shortcut` op pointing at an existing target uid", async () => {
|
||||
it('runs a `shortcut` op pointing at an existing target uid', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const target = `/${username}/Documents/batch-sc-target`;
|
||||
@@ -2441,10 +2581,7 @@ describe('LegacyFSController.suggestApps', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
const spy = vi.spyOn(server.services.suggestedApps, 'getSuggestedApps');
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
@@ -2476,10 +2613,7 @@ describe('LegacyFSController.suggestApps', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
const spy = vi.spyOn(server.services.suggestedApps, 'getSuggestedApps');
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -2532,10 +2666,7 @@ describe('LegacyFSController.readdirSubdomains', () => {
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirSubdomains(
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
controller.readdirSubdomains(makeReq({ body: {}, actor }), res),
|
||||
);
|
||||
const rows = captured.body as Array<{ subdomain: string }>;
|
||||
expect(rows.some((r) => r.subdomain === sd)).toBe(true);
|
||||
@@ -2613,3 +2744,190 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
expect(typeof body.thumbnail).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /get-launch-apps ────────────────────────────────────────────
|
||||
//
|
||||
// `recent` is a launch-metadata producer: it returns `index_url` for
|
||||
// apps the user has opened before. The taskbar happens to launch these
|
||||
// by name (so AppDriver's hosted-backing guard applies), but the field
|
||||
// is served regardless, so the guard is applied here too.
|
||||
|
||||
describe('LegacyFSController GET /get-launch-apps', () => {
|
||||
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
|
||||
|
||||
// The route is an inline lambda in `registerRoutes`, so pull it off a
|
||||
// freshly-registered router rather than calling a named method.
|
||||
const getLaunchAppsHandler = () => {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
controller as unknown as {
|
||||
registerRoutes: (r: PuterRouter) => void;
|
||||
}
|
||||
).registerRoutes(router);
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === '/get-launch-apps',
|
||||
);
|
||||
if (!route) throw new Error('No GET /get-launch-apps route');
|
||||
return route.handler;
|
||||
};
|
||||
|
||||
const recordOpen = async (userId: number, appUid: string) => {
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
|
||||
[appUid, userId, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRecent = async (
|
||||
actor: Actor,
|
||||
): Promise<Array<Record<string, unknown>>> => {
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, async () => {
|
||||
await getLaunchAppsHandler()(makeReq({ actor }), res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
});
|
||||
return (captured.body as { recent: Array<Record<string, unknown>> })
|
||||
.recent;
|
||||
};
|
||||
|
||||
const makeHostedApp = async (userId: number, sub: string) => {
|
||||
const name = `launch-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{ name, title: 'Launchable', index_url: hostedUrl(sub) },
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
return app as { uid: string };
|
||||
};
|
||||
|
||||
it('returns index_url while the hosted subdomain is owned', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `live-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(String(entry?.index_url)).toContain(sub);
|
||||
expect(entry?.privateAccess).toBeUndefined();
|
||||
});
|
||||
|
||||
it('nulls index_url and denies launch once the subdomain is deleted', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `gone-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId },
|
||||
);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.index_url).toBeNull();
|
||||
expect(entry?.privateAccess).toMatchObject({
|
||||
hasAccess: false,
|
||||
reason: 'hosted_backing_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('nulls index_url once the subdomain is reclaimed by another user', async () => {
|
||||
const owner = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const sub = `reclaim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await makeHostedApp(owner.userId, sub);
|
||||
await recordOpen(owner.userId, app.uid);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
await server.stores.subdomain.create({
|
||||
userId: attacker.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const entry = (await fetchRecent(owner.actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBeNull();
|
||||
expect(entry?.privateAccess).toMatchObject({
|
||||
hasAccess: false,
|
||||
reason: 'hosted_backing_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when the subdomain lookup errors', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = `flaky-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
const app = await makeHostedApp(userId, sub);
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const spy = vi
|
||||
.spyOn(server.stores.subdomain, 'getBySubdomain')
|
||||
.mockRejectedValue(new Error('db down'));
|
||||
try {
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBeNull();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves non-hosted index_urls untouched', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const name = `ext-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const app = (await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'External',
|
||||
index_url: 'https://dev-owned-domain.example/',
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
)) as { uid: string };
|
||||
await recordOpen(userId, app.uid);
|
||||
|
||||
const entry = (await fetchRecent(actor)).find(
|
||||
(a) => a.uuid === app.uid,
|
||||
);
|
||||
expect(entry?.index_url).toBe('https://dev-owned-domain.example/');
|
||||
expect(entry?.privateAccess).toBeUndefined();
|
||||
});
|
||||
|
||||
// The rows are fetched in one batched lookup, which returns a map — the
|
||||
// handler has to re-impose the recency order the uid list carries.
|
||||
it('preserves the most-recent-first order of the underlying uid list', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const first = await makeHostedApp(userId, 'unregistered-a');
|
||||
const second = await makeHostedApp(userId, 'unregistered-b');
|
||||
const third = await makeHostedApp(userId, 'unregistered-c');
|
||||
|
||||
await recordOpen(userId, first.uid);
|
||||
await recordOpen(userId, second.uid);
|
||||
await recordOpen(userId, third.uid);
|
||||
|
||||
const recentUids = await server.stores.app.getRecentAppOpens(userId, {
|
||||
limit: 10,
|
||||
});
|
||||
const returned = (await fetchRecent(actor)).map((a) => a.uuid);
|
||||
|
||||
expect(returned).toEqual(recentUids);
|
||||
});
|
||||
});
|
||||
@@ -3,32 +3,42 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import Busboy from 'busboy';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { effectiveActorApp, isAccessTokenActor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
assertNotSuspended,
|
||||
assertVerifiedAccount,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
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 {
|
||||
buildHostedBackingDenial,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { FS_COSTS } from './costs.js';
|
||||
@@ -44,7 +54,6 @@ import {
|
||||
signingConfigFromAppConfig,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
|
||||
type RouterCache = Map<string, RequestHandler | null>;
|
||||
|
||||
@@ -158,7 +167,8 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
router.get('/get-launch-apps', apiOptions, async (req, res) => {
|
||||
const recommendedSvc = this.services.recommendedApps as unknown as
|
||||
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
|
||||
| { getRecommendedApps?: () => Promise<unknown[]> }
|
||||
| undefined;
|
||||
const recommended = recommendedSvc?.getRecommendedApps
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
@@ -175,33 +185,60 @@ export class LegacyFSController extends PuterController {
|
||||
) => Promise<string[]>;
|
||||
}
|
||||
).getRecentAppOpens?.(userId, { limit: 10 })) ?? [];
|
||||
const apps: unknown[] = [];
|
||||
for (const uid of recentUids) {
|
||||
const app = await (
|
||||
this.stores.app as unknown as {
|
||||
getByUid: (
|
||||
uid: string,
|
||||
) => Promise<Record<string, unknown> | null>;
|
||||
}
|
||||
).getByUid(uid);
|
||||
if (app) {
|
||||
apps.push({
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon ?? null,
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
// An app with no owner isn't owned by a Puter user —
|
||||
// it's an "external" (origin-bootstrapped) app.
|
||||
external:
|
||||
app.owner_user_id == null ||
|
||||
app.owner_user_id === '',
|
||||
});
|
||||
// One batched read for the rows, then the backing checks
|
||||
// concurrently. Serially awaiting a lookup per uid put ~2
|
||||
// round trips of latency on every desktop boot.
|
||||
const appsByUid = await (
|
||||
this.stores.app as unknown as {
|
||||
getByUids: (
|
||||
uids: string[],
|
||||
) => Promise<Map<string, Record<string, unknown>>>;
|
||||
}
|
||||
}
|
||||
recent = apps;
|
||||
).getByUids(recentUids);
|
||||
|
||||
// `recentUids` is ordered most-recent-first; preserve it.
|
||||
const orderedApps = recentUids
|
||||
.map((uid) => appsByUid.get(uid))
|
||||
.filter((app): app is Record<string, unknown> =>
|
||||
Boolean(app),
|
||||
);
|
||||
|
||||
// Don't hand out an index_url whose puter-hosted backing is
|
||||
// gone or reclaimed. The taskbar launches recents by name (so
|
||||
// AppDriver's guard applies), but this list is a
|
||||
// launch-metadata producer like any other — a future consumer
|
||||
// reading index_url straight off it shouldn't inherit a stale
|
||||
// origin.
|
||||
const backingGoneFlags = await Promise.all(
|
||||
orderedApps.map((app) =>
|
||||
hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
subdomainStore: this.stores.subdomain,
|
||||
config: this.config,
|
||||
}).catch(() => true),
|
||||
),
|
||||
);
|
||||
|
||||
recent = orderedApps.map((app, index) => {
|
||||
const backingGone = backingGoneFlags[index];
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon ?? null,
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: backingGone ? null : app.index_url,
|
||||
...(backingGone
|
||||
? { privateAccess: buildHostedBackingDenial() }
|
||||
: {}),
|
||||
// An app with no owner isn't owned by a Puter user —
|
||||
// it's an "external" (origin-bootstrapped) app.
|
||||
external:
|
||||
app.owner_user_id == null ||
|
||||
app.owner_user_id === '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ recommended, recent });
|
||||
@@ -312,6 +349,13 @@ export class LegacyFSController extends PuterController {
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
|
||||
// Presence of `cursor` (null means "first page") or `includeTotal`
|
||||
// opts into the paginated `{items, cursor?, total?}` envelope.
|
||||
// Requests without pagination params keep the bare-array response.
|
||||
const paginated =
|
||||
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
|
||||
body.includeTotal === true;
|
||||
|
||||
if (this.#isRootPathRef(body)) {
|
||||
const { listRootEntries } =
|
||||
await import('../../services/fs/rootListing.js');
|
||||
@@ -341,6 +385,15 @@ export class LegacyFSController extends PuterController {
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: shaped,
|
||||
...(body.includeTotal === true
|
||||
? { total: shaped.length }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(shaped);
|
||||
return;
|
||||
}
|
||||
@@ -359,10 +412,37 @@ export class LegacyFSController extends PuterController {
|
||||
'list',
|
||||
);
|
||||
|
||||
const children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
sortBy: this.#parseSortBy(body),
|
||||
sortOrder: this.#parseSortOrder(body),
|
||||
});
|
||||
const sortBy = this.#parseSortBy(body);
|
||||
const sortOrder = this.#parseSortOrder(body);
|
||||
const limit =
|
||||
typeof body.limit === 'number' || typeof body.limit === 'string'
|
||||
? Number(body.limit)
|
||||
: undefined;
|
||||
const offset =
|
||||
typeof body.offset === 'number' || typeof body.offset === 'string'
|
||||
? Number(body.offset)
|
||||
: undefined;
|
||||
|
||||
let children;
|
||||
let cursor: string | undefined;
|
||||
if (paginated) {
|
||||
const page = await this.services.fs.listDirectoryPage(parent.uuid, {
|
||||
limit,
|
||||
cursor:
|
||||
typeof body.cursor === 'string' ? body.cursor : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
children = page.entries;
|
||||
cursor = page.cursor;
|
||||
} else {
|
||||
children = await this.services.fs.listDirectory(parent.uuid, {
|
||||
limit: Number.isFinite(limit) ? limit : undefined,
|
||||
offset: Number.isFinite(offset) ? offset : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
const suggestions =
|
||||
await this.services.suggestedApps.getSuggestedAppsForEntries(
|
||||
@@ -385,6 +465,19 @@ export class LegacyFSController extends PuterController {
|
||||
toLegacyEntry(this.clients.event, c, { appsById }),
|
||||
),
|
||||
);
|
||||
|
||||
if (paginated) {
|
||||
const total =
|
||||
body.includeTotal === true
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: shaped,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json(shaped);
|
||||
};
|
||||
|
||||
@@ -559,7 +652,9 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
Record<string, unknown> | null | undefined,
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -826,7 +921,12 @@ export class LegacyFSController extends PuterController {
|
||||
res.json(shaped);
|
||||
};
|
||||
|
||||
read = async (req: Request, res: Response, options = {}): Promise<void> => {
|
||||
read = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next?: NextFunction,
|
||||
options: { realMime?: boolean } = {},
|
||||
) => {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = asRecord(req.query);
|
||||
|
||||
@@ -867,7 +967,10 @@ export class LegacyFSController extends PuterController {
|
||||
// GUI) expect the raw-Blob shape. Use `/fs/read` for type-aware
|
||||
// streaming.
|
||||
if (options.realMime) {
|
||||
res.setHeader('Content-Type', contentTypeFromMime(entry.name));
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
contentTypeFromMime(entry.name) as string,
|
||||
);
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
}
|
||||
@@ -934,20 +1037,25 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
req.actor = actor;
|
||||
// This endpoint authenticates the token by hand and never runs the
|
||||
// route gate chain, so the suspension and pending-verification checks
|
||||
// that guard every other authenticated FS route have to run here.
|
||||
assertNotSuspended(actor!.user);
|
||||
assertVerifiedAccount(actor!.user);
|
||||
|
||||
req.actor = actor!;
|
||||
Context.set('actor', actor);
|
||||
|
||||
// Forward back to regular read after setting actor
|
||||
return this.read(req, res, { realMime: true });
|
||||
return this.read(req, res, undefined, { realMime: true });
|
||||
};
|
||||
|
||||
// -- Signed-URL + meta routes ----------------------------------------
|
||||
|
||||
/**
|
||||
* POST /sign
|
||||
* Body: `{ items: [{ uid?, path?, action }], app_uid? }`. Returns
|
||||
* `{ signatures: [...], token? }`. Apps may only sign files under their
|
||||
* own AppData subtree.
|
||||
* POST /sign Body: `{ items: [{ uid?, path?, action }], app_uid? }`.
|
||||
* Returns `{ signatures: [...], token? }`. Apps may only sign files under
|
||||
* their own AppData subtree.
|
||||
*/
|
||||
sign = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
@@ -975,7 +1083,8 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1072,10 +1181,10 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /writeFile?uid=<uid>&operation=<op>
|
||||
* Signature-authenticated multipart upload. `operation` dispatches to one
|
||||
* of write/copy/move/mkdir/delete/rename/trash. Signature must be valid
|
||||
* for `write` action on `uid`.
|
||||
* POST /writeFile?uid=<uid>&operation=<op> Signature-authenticated
|
||||
* multipart upload. `operation` dispatches to one of
|
||||
* write/copy/move/mkdir/delete/rename/trash. Signature must be valid for
|
||||
* `write` action on `uid`.
|
||||
*/
|
||||
writeFile = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
@@ -1126,35 +1235,35 @@ export class LegacyFSController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ACL re-check: require an authenticated actor with write
|
||||
// permission. A valid write signature alone is not sufficient —
|
||||
// the caller must also pass ACL, preventing exploitation of
|
||||
// leaked write URLs by read-only share recipients.
|
||||
const callerActor = this.#requireActor(req);
|
||||
if (operation === 'write') {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
}
|
||||
if (operation === 'write') {
|
||||
const body = asRecord(req.body);
|
||||
const parentEntry = targetEntry.isDir
|
||||
? targetEntry
|
||||
: await this.#resolveParentOfEntry(targetEntry);
|
||||
const name =
|
||||
typeof body.name === 'string'
|
||||
? body.name
|
||||
: targetEntry.isDir
|
||||
? `upload-${Date.now()}`
|
||||
: targetEntry.name;
|
||||
const targetPath =
|
||||
parentEntry.path === '/'
|
||||
? `/${name}`
|
||||
: `${parentEntry.path}/${name}`;
|
||||
let targetPath: string;
|
||||
if (targetEntry.isDir) {
|
||||
const name =
|
||||
typeof body.name === 'string'
|
||||
? body.name
|
||||
: `upload-${Date.now()}`;
|
||||
targetPath =
|
||||
targetEntry.path === '/'
|
||||
? `/${name}`
|
||||
: `${targetEntry.path}/${name}`;
|
||||
await assertCanCreate(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetPath,
|
||||
);
|
||||
} else {
|
||||
targetPath = targetEntry.path;
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetPath,
|
||||
'write',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse multipart and pipe the first `file` part into fsService.write.
|
||||
const uploadResult = await this.#multipartWrite(
|
||||
@@ -1294,10 +1403,9 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /file?uid=<uid>&signature=...&expires=...
|
||||
* Signature-authenticated file read. Directories return a signed listing
|
||||
* of children; files stream bytes (with Range support when `download`
|
||||
* isn't requested).
|
||||
* GET /file?uid=<uid>&signature=...&expires=... Signature-authenticated
|
||||
* file read. Directories return a signed listing of children; files stream
|
||||
* bytes (with Range support when `download` isn't requested).
|
||||
*/
|
||||
file = async (req: Request, res: Response): Promise<void> => {
|
||||
const query = asRecord(req.query);
|
||||
@@ -1392,9 +1500,9 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /open_item — resolve an entry, grant the default suggested app
|
||||
* write access to it, and return a signed URL + user-app token so the
|
||||
* launched app can read/write the file via its app-under-user token.
|
||||
* POST /open_item — resolve an entry, grant the default suggested app write
|
||||
* access to it, and return a signed URL + user-app token so the launched
|
||||
* app can read/write the file via its app-under-user token.
|
||||
*
|
||||
* Matches v1 semantics: permission is always granted as `write` — the
|
||||
* underlying user's permission check still caps the effective access
|
||||
@@ -1468,8 +1576,8 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /auth/request-app-root-dir — an app-under-user requests stat on
|
||||
* its own app root directory. The app must own itself.
|
||||
* POST /auth/request-app-root-dir — an app-under-user requests stat on its
|
||||
* own app root directory. The app must own itself.
|
||||
*/
|
||||
requestAppRootDir = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
@@ -1516,7 +1624,10 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -1654,14 +1765,6 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveParentOfEntry(entry: { path: string; userId: number }) {
|
||||
const parentPath = pathPosix.dirname(entry.path);
|
||||
const parent = await resolveV1Selector(this.stores.fsEntry, {
|
||||
path: parentPath,
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
async #multipartWrite(
|
||||
req: Request,
|
||||
userId: number,
|
||||
@@ -2180,12 +2283,21 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
#serializeBatchError(err: unknown): Record<string, unknown> {
|
||||
if (err instanceof HttpError) {
|
||||
return {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: true,
|
||||
status: err.statusCode,
|
||||
message: err.message,
|
||||
code: err.legacyCode ?? err.code,
|
||||
};
|
||||
// Same as the terminal errorHandler: extra fields (e.g.
|
||||
// `entry_name` on item_with_same_name_exists) ride along
|
||||
// without clobbering the canonical slots.
|
||||
if (err.fields) {
|
||||
for (const [k, v] of Object.entries(err.fields)) {
|
||||
if (!(k in payload)) payload[k] = v;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { error: true, status: 500, message: err.message };
|
||||
@@ -2233,7 +2345,7 @@ export class LegacyFSController extends PuterController {
|
||||
#parseSortBy(
|
||||
body: Record<string, unknown>,
|
||||
): 'name' | 'modified' | 'type' | 'size' | null {
|
||||
const raw = getString(body, 'sort_by');
|
||||
const raw = getString(body, 'sortBy') || getString(body, 'sort_by');
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
return (
|
||||
@@ -2244,7 +2356,8 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
#parseSortOrder(body: Record<string, unknown>): 'asc' | 'desc' | null {
|
||||
const raw = getString(body, 'sort_order');
|
||||
const raw =
|
||||
getString(body, 'sortOrder') || getString(body, 'sort_order');
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
return (['asc', 'desc'] as const).find((v) => v === normalized) ?? null;
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
@@ -43,9 +44,8 @@ import type { IConfig } from '../../types.js';
|
||||
/**
|
||||
* Shared helpers used by the legacy FS route shims (LegacyFSController).
|
||||
*
|
||||
* Legacy clients speak snake_case and expect specific response shapes —
|
||||
* these helpers encapsulate that translation so the route handlers stay
|
||||
* terse.
|
||||
* Legacy clients speak snake_case and expect specific response shapes — these
|
||||
* helpers encapsulate that translation so the route handlers stay terse.
|
||||
*/
|
||||
|
||||
// -- Body parsing -----------------------------------------------------
|
||||
@@ -220,15 +220,15 @@ export async function assertAccess(
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule is
|
||||
* write on the parent, but we also allow it when the actor has explicit
|
||||
* write on the target itself — this covers an app creating its own
|
||||
* `/<user>/AppData/<app_uid>` folder (parent `AppData` is off-limits, but
|
||||
* the target is the app's own subtree per ACLService's short-circuit) and
|
||||
* shares granted directly on a not-yet-created path.
|
||||
* Authorize creation of a new entry at `targetPath`. The standard rule is write
|
||||
* on the parent, but we also allow it when the actor has explicit write on the
|
||||
* target itself — this covers an app creating its own
|
||||
* `/<user>/AppData/<app_uid>` folder (parent `AppData` is off-limits, but the
|
||||
* target is the app's own subtree per ACLService's short-circuit) and shares
|
||||
* granted directly on a not-yet-created path.
|
||||
*
|
||||
* On failure, delegates to `assertAccess` on the parent so the error
|
||||
* shape stays identical to the previous parent-only check.
|
||||
* On failure, delegates to `assertAccess` on the parent so the error shape
|
||||
* stays identical to the previous parent-only check.
|
||||
*/
|
||||
export async function assertCanCreate(
|
||||
aclService: ACLService,
|
||||
@@ -272,20 +272,21 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
|
||||
/**
|
||||
* Convert an AppStore-normalized app row into the v1 `associated_app` shape
|
||||
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
|
||||
* because the v1 wire contract emits them that way and existing clients key
|
||||
* off it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
* because the v1 wire contract emits them that way and existing clients key off
|
||||
* it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
*
|
||||
* Two redactions, because `associatedAppId` can name an app the actor does
|
||||
* not own (the column is client-writable display metadata, never trusted for
|
||||
* authz — see FSController) and may point cross-tenant:
|
||||
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
|
||||
* they're internal user references with no client-side display use.
|
||||
* - For private or protected apps, the direct hosting URL and launch
|
||||
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
|
||||
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
|
||||
* withholds `index_url` from callers without read entitlement. Display
|
||||
* fields (name, icon, title) still pass through so the GUI can label the
|
||||
* file's associated app.
|
||||
* Two redactions, because `associatedAppId` can name an app the actor does not
|
||||
* own (the column is client-writable display metadata, never trusted for authz
|
||||
* — see FSController) and may point cross-tenant:
|
||||
*
|
||||
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted — they're
|
||||
* internal user references with no client-side display use.
|
||||
* - For private or protected apps, the direct hosting URL and launch capability
|
||||
* flags (`index_url`, `godmode`, `maximize_on_start`, `background`,
|
||||
* `metadata`) are dropped. This mirrors AppDriver, which withholds
|
||||
* `index_url` from callers without read entitlement. Display fields (name,
|
||||
* icon, title) still pass through so the GUI can label the file's associated
|
||||
* app.
|
||||
*/
|
||||
function mapAppForLegacyAssociatedApp(
|
||||
app: Record<string, unknown>,
|
||||
@@ -319,13 +320,15 @@ function mapAppForLegacyAssociatedApp(
|
||||
/**
|
||||
* Batch-load `associated_app` payloads for a set of entries. Dedupes app ids
|
||||
* across the input, hands them to `AppStore.getByIds` (one pipelined Redis
|
||||
* MGET + a single `id IN (…)` query for any cache misses), and returns a map
|
||||
* keyed by app id holding the v1-shaped embed. Callers pass the result to
|
||||
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a
|
||||
* second round-trip.
|
||||
* MGET
|
||||
*
|
||||
* Empty input short-circuits — readdir on a directory of plain files makes
|
||||
* zero extra calls.
|
||||
* - A single `id IN (…)` query for any cache misses), and returns a map keyed by
|
||||
* app id holding the v1-shaped embed. Callers pass the result to
|
||||
* `toLegacyEntry` via `opts.appsById` so each entry hydrates without a second
|
||||
* round-trip.
|
||||
*
|
||||
* Empty input short-circuits — readdir on a directory of plain files makes zero
|
||||
* extra calls.
|
||||
*/
|
||||
export async function loadLegacyAssociatedApps(
|
||||
appStore: AppRowLookup,
|
||||
@@ -348,12 +351,55 @@ export async function loadLegacyAssociatedApps(
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the snake_case entry shape legacy clients expect. If `thumbnail`
|
||||
* is set, asks the thumbnail extension (via `thumbnail.read` event) to swap
|
||||
* an S3 URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate
|
||||
* `is_empty` (directories) and `owner` — both are required fields per
|
||||
* the legacy stat contract but need extra DB lookups. Pass `appsById`
|
||||
* (built via `loadLegacyAssociatedApps`) to populate `associated_app`.
|
||||
* The v1 `type` field: a MIME content-type (e.g. "image/png; charset=utf-8")
|
||||
* for files, or "folder" for directories. The GUI's icon lookup keys off
|
||||
* `type.startsWith('image/')`, so a bare extension breaks icon selection.
|
||||
*/
|
||||
export function fsEntryMimeType(entry: {
|
||||
isDir: boolean;
|
||||
name: string;
|
||||
}): string | null {
|
||||
return entry.isDir ? 'folder' : contentTypeFromMime(entry.name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap an S3 thumbnail key for a signed URL via the thumbnail extension
|
||||
* (`thumbnail.read` event). Returns the input unchanged when there's no
|
||||
* thumbnail or no event client; returns null if signing yields nothing.
|
||||
*/
|
||||
export async function signEntryThumbnail(
|
||||
eventClient: EventClient | undefined,
|
||||
uuid: string,
|
||||
thumbnail: string | null,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
typeof thumbnail !== 'string' ||
|
||||
thumbnail.length === 0 ||
|
||||
!eventClient
|
||||
) {
|
||||
return thumbnail ?? null;
|
||||
}
|
||||
const thumbnailEntry = { uuid, thumbnail };
|
||||
try {
|
||||
// emitAndWait — listener mutates `thumbnail` on the payload; plain
|
||||
// `emit` is fire-and-forget and would drop the rewrite.
|
||||
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
}
|
||||
return typeof thumbnailEntry.thumbnail === 'string' &&
|
||||
thumbnailEntry.thumbnail.length > 0
|
||||
? thumbnailEntry.thumbnail
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the snake_case entry shape legacy clients expect. If `thumbnail` is
|
||||
* set, asks the thumbnail extension (via `thumbnail.read` event) to swap an S3
|
||||
* URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty`
|
||||
* (directories) and `owner` — both are required fields per the legacy stat
|
||||
* contract but need extra DB lookups. Pass `appsById` (built via
|
||||
* `loadLegacyAssociatedApps`) to populate `associated_app`.
|
||||
*/
|
||||
export async function toLegacyEntry(
|
||||
eventClient: EventClient | undefined,
|
||||
@@ -367,13 +413,7 @@ export async function toLegacyEntry(
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const dirname = pathPosix.dirname(entry.path);
|
||||
// v1 contract: `type` is a MIME content-type (e.g. "image/png; charset=utf-8")
|
||||
// for files, or "folder" for directories. The GUI's icon lookup does
|
||||
// `type.startsWith('image/')` etc., so a bare extension breaks every
|
||||
// banner that falls through the name-extension ladder in item_icon.js.
|
||||
const mimeType = entry.isDir
|
||||
? 'folder'
|
||||
: contentTypeFromMime(entry.name) || null;
|
||||
const mimeType = fsEntryMimeType(entry);
|
||||
|
||||
const pathComponents = entry.path.split('/');
|
||||
const appdata_app =
|
||||
@@ -444,28 +484,11 @@ export async function toLegacyEntry(
|
||||
}
|
||||
|
||||
// Let the thumbnail extension swap an s3:// key for a signed URL.
|
||||
if (
|
||||
typeof response.thumbnail === 'string' &&
|
||||
(response.thumbnail as string).length > 0 &&
|
||||
eventClient
|
||||
) {
|
||||
const thumbnailEntry = {
|
||||
uuid: entry.uuid,
|
||||
thumbnail: response.thumbnail as string,
|
||||
};
|
||||
try {
|
||||
// emitAndWait — listener mutates `thumbnail` on the payload;
|
||||
// plain `emit` is fire-and-forget and would drop the rewrite.
|
||||
await eventClient.emitAndWait('thumbnail.read', thumbnailEntry, {});
|
||||
} catch {
|
||||
// ignore — non-critical.
|
||||
}
|
||||
response.thumbnail =
|
||||
typeof thumbnailEntry.thumbnail === 'string' &&
|
||||
thumbnailEntry.thumbnail.length > 0
|
||||
? thumbnailEntry.thumbnail
|
||||
: null;
|
||||
}
|
||||
response.thumbnail = await signEntryThumbnail(
|
||||
eventClient,
|
||||
entry.uuid,
|
||||
response.thumbnail as string | null,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -475,8 +498,8 @@ export { normalizeAbsolutePath };
|
||||
// -- Signing ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull the signing config off the app config. Throws if either value is
|
||||
* missing — these are required for signed URL routes to function.
|
||||
* Pull the signing config off the app config. Throws if either value is missing
|
||||
* — these are required for signed URL routes to function.
|
||||
*/
|
||||
export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
const secret = config.url_signature_secret;
|
||||
@@ -498,9 +521,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
|
||||
return { secret, apiBaseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: turn an FSEntry into a signed-file response object.
|
||||
*/
|
||||
/** Convenience wrapper: turn an FSEntry into a signed-file response object. */
|
||||
export function signEntry(
|
||||
entry: {
|
||||
uuid: string;
|
||||
|
||||
@@ -3,22 +3,27 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { FSEntry, FSEntryWriteInput } from '../../stores/fs/FSEntry.js';
|
||||
import type { Readable } from 'node:stream';
|
||||
import type {
|
||||
FSEntry,
|
||||
FSEntrySubdomain,
|
||||
FSEntryWriteInput,
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
|
||||
export type UploadMode = 'single' | 'multipart';
|
||||
|
||||
@@ -129,3 +134,100 @@ export interface WriteResponse {
|
||||
requestedThumbnail?: string | null;
|
||||
contentHashSha256?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
|
||||
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
|
||||
* references, the storage columns (`bucket`, `bucketRegion`), the owning
|
||||
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
|
||||
* reach the wire. Entries are addressed by `uuid`.
|
||||
*
|
||||
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
|
||||
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
|
||||
* it, so dropping it would break them.
|
||||
*
|
||||
* The `?: never` members below are guards, not fields: they make a raw
|
||||
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
|
||||
* them this type is just a structural subset of `FSEntry`, so an unsanitized
|
||||
* row would be silently assignable and the distinction would buy nothing.
|
||||
*
|
||||
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
|
||||
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
|
||||
* not catch a violation here.
|
||||
*/
|
||||
export interface ClientFSEntry {
|
||||
uuid: string;
|
||||
uid: string;
|
||||
parentUid: string | null;
|
||||
path: string;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
isShortcut: boolean;
|
||||
shortcutTo: number | null;
|
||||
isSymlink: boolean;
|
||||
symlinkPath: string | null;
|
||||
isPublic: boolean | null;
|
||||
immutable: boolean;
|
||||
metadata: string | null;
|
||||
modified: number;
|
||||
created: number | null;
|
||||
accessed: number | null;
|
||||
size: number | null;
|
||||
layout: string | null;
|
||||
subdomains: FSEntrySubdomain[];
|
||||
workers: FSEntrySubdomain[];
|
||||
hasWebsite: boolean;
|
||||
suggestedApps: unknown[];
|
||||
|
||||
id?: never;
|
||||
userId?: never;
|
||||
parentId?: never;
|
||||
associatedAppId?: never;
|
||||
bucket?: never;
|
||||
bucketRegion?: never;
|
||||
publicToken?: never;
|
||||
fileRequestToken?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire counterparts of the write responses. The bare `…Response` types describe
|
||||
* what the service produces internally (a real `FSEntry`); these describe what
|
||||
* the controller sends after sanitizing. Keeping them distinct is what makes
|
||||
* "did this response get sanitized?" a question the compiler answers.
|
||||
*/
|
||||
/**
|
||||
* The presigned-upload envelope minus the storage internals. A client uploads
|
||||
* to the presigned `url` / `multipartPartUrls`, which already carry everything
|
||||
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
|
||||
* they name where a user's bytes physically live.
|
||||
*/
|
||||
export type ClientSignedWriteResponse = Omit<
|
||||
SignedWriteResponse,
|
||||
'fsEntry' | 'bucket' | 'bucketRegion' | 'objectKey'
|
||||
> & { fsEntry?: ClientFSEntry };
|
||||
|
||||
export type ClientSignMultipartPartsResponse = Omit<
|
||||
SignMultipartPartsResponse,
|
||||
'bucket' | 'bucketRegion' | 'objectKey'
|
||||
>;
|
||||
|
||||
export type ClientCompleteWriteResponse = Omit<
|
||||
CompleteWriteResponse,
|
||||
'fsEntry'
|
||||
> & { fsEntry: ClientFSEntry };
|
||||
|
||||
export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
|
||||
fsEntry: ClientFSEntry;
|
||||
};
|
||||
|
||||
/**
|
||||
* A directory-listing entry: a sanitized entry plus the three fields a client
|
||||
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
|
||||
* stored value is an S3 key, which is useless to a client), and the resolved
|
||||
* `associatedApp`.
|
||||
*/
|
||||
export type ClientReaddirEntry = ClientFSEntry & {
|
||||
type: string | null;
|
||||
thumbnail: string | null;
|
||||
associatedApp: Record<string, unknown> | null;
|
||||
};
|
||||
@@ -277,6 +277,70 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(html).toContain('Cool App');
|
||||
});
|
||||
|
||||
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const secretUrl = `https://secret-${name}.example.com/`;
|
||||
// `is_private` is a READ_ONLY_COLUMN, so it can't be set through the
|
||||
// store's create/update allow-list — insert the private row directly.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`description\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
`app-${uuidv4()}`,
|
||||
name,
|
||||
'Private App',
|
||||
'a private app row',
|
||||
secretUrl,
|
||||
userId,
|
||||
1,
|
||||
],
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/app/:name',
|
||||
makeReq({ params: { name }, path: `/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
// Public meta (title) is fine to render...
|
||||
expect(html).toContain('Private App');
|
||||
// ...but the private hosting URL and owner id must be redacted.
|
||||
expect(html).not.toContain(secretUrl);
|
||||
expect(html).not.toContain('owner_user_id');
|
||||
});
|
||||
|
||||
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `pub-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const indexUrl = `https://public-${name}.example.com/`;
|
||||
await server.stores.app.create(
|
||||
{ name, title: 'Public App', index_url: indexUrl },
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/app/:name',
|
||||
makeReq({ params: { name }, path: `/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
expect(html).toContain('Public App');
|
||||
// The GUI re-reads the app through the driver before launching, which
|
||||
// is where the entitlement gate and hosted-backing guard run. Baking
|
||||
// a launch URL into server-rendered HTML buys nothing and costs the
|
||||
// lookups those guards require.
|
||||
expect(html).not.toContain(indexUrl);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,23 +3,25 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import { toAppShellView } from '../../util/appShellView.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import type {
|
||||
PuterHomepageService,
|
||||
@@ -32,8 +34,8 @@ import type {
|
||||
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
|
||||
* files referenced from the shell).
|
||||
*
|
||||
* All root-subdomain-only. Registered last in the controller list so the
|
||||
* static catch-all doesn't shadow specific API routes.
|
||||
* All root-subdomain-only. Registered last in the controller list so the static
|
||||
* catch-all doesn't shadow specific API routes.
|
||||
*/
|
||||
export class HomepageController extends PuterController {
|
||||
registerRoutes(router: PuterRouter) {
|
||||
@@ -101,6 +103,12 @@ export class HomepageController extends PuterController {
|
||||
? safeJsonParse(app.metadata)
|
||||
: (app.metadata as Record<string, unknown> | null)) ??
|
||||
{};
|
||||
// Never bake the raw store row into the shell — it exposes
|
||||
// `index_url`, `owner_user_id`, and moderation flags. The
|
||||
// shell only needs a preview; the GUI re-reads the app
|
||||
// through the apps driver before launching, and that read is
|
||||
// where the entitlement gate and hosted-backing guard run.
|
||||
const clientApp = toAppShellView(app);
|
||||
await sendShell(req, res, {
|
||||
title: String(app.title ?? name),
|
||||
description: String(app.description ?? ''),
|
||||
@@ -110,7 +118,7 @@ export class HomepageController extends PuterController {
|
||||
typeof metadata.social_image === 'string'
|
||||
? metadata.social_image
|
||||
: undefined,
|
||||
app: app as Record<string, unknown>,
|
||||
app: clientApp as Record<string, unknown>,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -329,6 +329,54 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
expect(decoded).toMatchObject({ referrer: 'http://ref.test' });
|
||||
});
|
||||
|
||||
it('bakes a whitelisted return_to (/app/<name>) into the state redirect_uri', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({
|
||||
params: { provider: 'custom' },
|
||||
query: { return_to: '/app/my-App_2' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(String(decoded?.redirect_uri)).toBe(
|
||||
`${TEST_ORIGIN}/app/my-App_2`,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a non-whitelisted return_to', async () => {
|
||||
const bad_values = [
|
||||
'/app/evil/extra',
|
||||
'/app/',
|
||||
'/app/name?x=1',
|
||||
'//evil.test',
|
||||
'/settings',
|
||||
`/app/${'a'.repeat(101)}`,
|
||||
];
|
||||
for (const return_to of bad_values) {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({
|
||||
params: { provider: 'custom' },
|
||||
query: { return_to },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const state = new URL(
|
||||
captured.redirectUrl ?? '',
|
||||
).searchParams.get('state');
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
|
||||
}
|
||||
});
|
||||
|
||||
it('signs revalidate-flow state with user_uuid + flow=revalidate', async () => {
|
||||
const userUuid = uuidv4();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -621,6 +669,76 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('redirects back to an /app/<name> landing after sign-in', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: `${TEST_ORIGIN}/app/some-app`,
|
||||
});
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email: `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(`${TEST_ORIGIN}/app/some-app`);
|
||||
});
|
||||
|
||||
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(true);
|
||||
await server.stores.user.update(created.user!.id, { suspended: 1 });
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: `${TEST_ORIGIN}/app/some-app`,
|
||||
});
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
const url = new URL(captured.redirectUrl ?? '');
|
||||
expect(url.pathname).toBe('/app/some-app');
|
||||
expect(url.searchParams.get('auth_error')).toBe('1');
|
||||
expect(url.searchParams.get('action')).toBe('login');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('appends oidc_login=true and uses popup-style URL when state is from a popup flow', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
@@ -47,6 +48,17 @@ const ALLOWED_ERRORS = [
|
||||
'signup_blocked',
|
||||
] as const;
|
||||
|
||||
// GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app
|
||||
// landings (/app/<name>, mirroring APP_NAME_REGEX in AppDriver). Strict
|
||||
// whitelist — never a client-supplied URL (no open redirect).
|
||||
function isWhitelistedReturnPath(path: string): boolean {
|
||||
return (
|
||||
path === '/desktop' ||
|
||||
path === '/dashboard' ||
|
||||
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function buildErrorRedirectUrl(
|
||||
origin: string,
|
||||
sourceFlow: string,
|
||||
@@ -62,6 +74,20 @@ function buildErrorRedirectUrl(
|
||||
? message
|
||||
: 'unauthorized';
|
||||
|
||||
// Land back on the whitelisted page the flow started from (e.g. an
|
||||
// /app/<name> landing) so the retry — and the eventual success — keeps
|
||||
// the user's destination. redirect_uri comes from the signed state and
|
||||
// was built server-side, but re-check the path against the whitelist.
|
||||
let pagePath = '/';
|
||||
if (typeof stateDecoded?.redirect_uri === 'string') {
|
||||
try {
|
||||
const statePath = new URL(stateDecoded.redirect_uri).pathname;
|
||||
if (isWhitelistedReturnPath(statePath)) pagePath = statePath;
|
||||
} catch {
|
||||
// unparsable redirect_uri: fall back to the root page
|
||||
}
|
||||
}
|
||||
|
||||
let params: URLSearchParams;
|
||||
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
|
||||
params = new URLSearchParams({
|
||||
@@ -84,7 +110,7 @@ function buildErrorRedirectUrl(
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
return `${base}/?${params.toString()}`;
|
||||
return `${base}${pagePath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function appendQueryParam(url: string, key: string, value: string): string {
|
||||
@@ -101,8 +127,8 @@ function constantTimeEqual(a: string, b: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `target` parses as a URL whose origin equals `origin`. Used to
|
||||
* clamp OIDC redirect targets — `startsWith` would accept
|
||||
* True iff `target` parses as a URL whose origin equals `origin`. Used to clamp
|
||||
* OIDC redirect targets — `startsWith` would accept
|
||||
* `https://puter.com.evil.com` against `https://puter.com`.
|
||||
*/
|
||||
function isSameOrigin(target: string, origin: string): boolean {
|
||||
@@ -166,15 +192,15 @@ export class OIDCController extends PuterController {
|
||||
|
||||
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
|
||||
|
||||
// Optional GUI return path so login started from /desktop or
|
||||
// /dashboard lands back there. Strict whitelist — never a
|
||||
// client-supplied URL (no open redirect).
|
||||
// Optional GUI return path so login started from /desktop,
|
||||
// /dashboard, or an /app/<name> landing lands back there.
|
||||
const rawReturnTo = Array.isArray(req.query.return_to)
|
||||
? req.query.return_to[0]
|
||||
: req.query.return_to;
|
||||
if (
|
||||
(flow === 'login' || flow === 'signup') &&
|
||||
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
|
||||
typeof rawReturnTo === 'string' &&
|
||||
isWhitelistedReturnPath(rawReturnTo)
|
||||
) {
|
||||
appRedirectUri = `${origin}${rawReturnTo}`;
|
||||
}
|
||||
@@ -497,13 +523,14 @@ if (window.opener) {
|
||||
|
||||
/**
|
||||
* Resolve an OIDC callback to a Puter user. In order:
|
||||
* 1. Existing link on (provider, sub) → that user.
|
||||
* 2. Email matches an existing account whose email is CONFIRMED →
|
||||
* link (provider, sub) to that user.
|
||||
* 3. Email matches an account whose email is UNCONFIRMED → refuse.
|
||||
* We don't know who owns an unconfirmed address, so linking would
|
||||
* let whoever controls the OIDC identity hijack a pending signup.
|
||||
* 4. Otherwise create a new user and link.
|
||||
*
|
||||
* 1. Existing link on (provider, sub) → that user.
|
||||
* 2. Email matches an existing account whose email is CONFIRMED → link
|
||||
* (provider, sub) to that user.
|
||||
* 3. Email matches an account whose email is UNCONFIRMED → refuse. We don't
|
||||
* know who owns an unconfirmed address, so linking would let whoever
|
||||
* controls the OIDC identity hijack a pending signup.
|
||||
* 4. Otherwise create a new user and link.
|
||||
*
|
||||
* Step 2 also requires `email_verified !== false` on the OIDC side,
|
||||
* otherwise a malicious IdP could claim someone else's email.
|
||||
|
||||
@@ -190,14 +190,16 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// 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, plus the shared per-tier AI
|
||||
// rate-limit / concurrency policy — these routes bypass the
|
||||
// `/drivers/call` dispatch (where the driver-declared limits are
|
||||
// enforced), so without the route gates they'd be unthrottled.
|
||||
// The four upstream-proxy routes accept exactly one credential
|
||||
// shape — a full-access API token minted from the dashboard:
|
||||
// `requireUserActor` keeps apps out, `allowFullAccessToken` admits
|
||||
// the PAT, and `noUserSession` rejects the account session ("root")
|
||||
// token. Each route also carries `requireVerified` (fresh accounts
|
||||
// must confirm their email before using the AI wire surface) and
|
||||
// the shared per-tier AI rate-limit / concurrency policy — these
|
||||
// routes bypass the `/drivers/call` dispatch (where the
|
||||
// driver-declared limits are enforced), so without the route gates
|
||||
// they'd be unthrottled.
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
@@ -210,6 +212,8 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
noUserSession: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
|
||||
@@ -46,8 +46,10 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
* ChatCompletionDriver, and translate the result (or NDJSON stream) back
|
||||
* into the vendor's response / SSE shape.
|
||||
*
|
||||
* All routes live on `subdomain: 'api'` and reject app-under-user actors —
|
||||
* only user actors may proxy.
|
||||
* All routes live on `subdomain: 'api'` and require a full-access API
|
||||
* token minted from the dashboard (user-scoped worker tokens also pass —
|
||||
* workers are never treated as root tokens). Apps, scoped tokens, and
|
||||
* account session ("root") tokens are rejected.
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
@@ -65,13 +67,18 @@ export class PuterAIController extends PuterController {
|
||||
req.actor?.user?.uuid || computeNetworkFingerprint(req);
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
// The wire routes want a delegated credential — a full-access
|
||||
// API token minted from the dashboard (or a user-scoped worker
|
||||
// token, which is never treated as a root token).
|
||||
// `requireUserActor` keeps apps out, `allowFullAccessToken`
|
||||
// admits the PAT, and `noUserSession` rejects the account's
|
||||
// session ("root") token — a copied session credential
|
||||
// shouldn't double as an AI API key. `requireVerified` keeps
|
||||
// fresh/unconfirmed accounts out, same as FS writes.
|
||||
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,
|
||||
noUserSession: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default!,
|
||||
scope: aiPolicyScope,
|
||||
|
||||
@@ -157,28 +157,27 @@ export class SystemController extends PuterController {
|
||||
});
|
||||
});
|
||||
|
||||
// -- GET /lsmod ----------------------------------------------
|
||||
// Enumerates driver interfaces and their implementors.
|
||||
// -- GET|POST /lsmod -----------------------------------------
|
||||
// Enumerates driver interfaces and their implementors. POST is
|
||||
// also routed because puter.js `drivers.list()` sends POST.
|
||||
|
||||
router.get(
|
||||
'/lsmod',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
(_req, res) => {
|
||||
const interfaces = {};
|
||||
for (const [key, driver] of Object.entries(this.drivers)) {
|
||||
const ifaceName = driver?.driverInterface;
|
||||
if (!ifaceName) continue;
|
||||
const driverName = driver.driverName ?? key;
|
||||
if (!interfaces[ifaceName]) {
|
||||
interfaces[ifaceName] = { implementors: {} };
|
||||
}
|
||||
interfaces[ifaceName].implementors[driverName] = {
|
||||
isDefault: Boolean(driver.isDefault),
|
||||
};
|
||||
const lsmod = (_req, res) => {
|
||||
const interfaces = {};
|
||||
for (const [key, driver] of Object.entries(this.drivers)) {
|
||||
const ifaceName = driver?.driverInterface;
|
||||
if (!ifaceName) continue;
|
||||
const driverName = driver.driverName ?? key;
|
||||
if (!interfaces[ifaceName]) {
|
||||
interfaces[ifaceName] = { implementors: {} };
|
||||
}
|
||||
res.json({ interfaces });
|
||||
},
|
||||
);
|
||||
interfaces[ifaceName].implementors[driverName] = {
|
||||
isDefault: Boolean(driver.isDefault),
|
||||
};
|
||||
}
|
||||
res.json({ interfaces });
|
||||
};
|
||||
router.get('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod);
|
||||
router.post('/lsmod', { subdomain: 'api', requireAuth: true }, lsmod);
|
||||
}
|
||||
|
||||
onServerStart() {}
|
||||
|
||||
@@ -53,7 +53,8 @@ export type LegacyErrorCodes =
|
||||
| 'password_required'
|
||||
| 'password_mismatch'
|
||||
| 'field_not_allowed_for_create'
|
||||
| 'account_is_not_verified';
|
||||
| 'account_is_not_verified'
|
||||
| 'app_or_api_token_required';
|
||||
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
|
||||
@@ -3,32 +3,34 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Actor } from '../actor';
|
||||
import type { TokenSource } from './types';
|
||||
|
||||
/**
|
||||
* Global Express.Request augmentation for v2.
|
||||
*
|
||||
* Every field declared here is populated by *global* middleware installed by
|
||||
* `PuterServer` (auth probe, body parser, etc.). Per-route fields stay local
|
||||
* to their handlers via `TypedRequest<O>` instead.
|
||||
* Every field declared here is populated by _global_ middleware installed by
|
||||
* `PuterServer` (auth probe, body parser, etc.). Per-route fields stay local to
|
||||
* their handlers via `TypedRequest<O>` instead.
|
||||
*
|
||||
* This module is import-only — it has no runtime exports. Files that consume
|
||||
* the augmented `Request` should `import './expressAugmentation'` (or any
|
||||
* file that imports it transitively) so TypeScript loads the declaration.
|
||||
* the augmented `Request` should `import './expressAugmentation'` (or any file
|
||||
* that imports it transitively) so TypeScript loads the declaration.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
@@ -40,6 +42,12 @@ declare global {
|
||||
/** The raw token string, if one was presented and parsed. */
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* Which request slot `token` came out of. Set alongside `token`;
|
||||
* routes that mint browser credentials gate on it.
|
||||
*/
|
||||
tokenSource?: TokenSource;
|
||||
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
@@ -54,8 +62,8 @@ declare global {
|
||||
auth_id?: string;
|
||||
/**
|
||||
* Short-lived server-signed JWT that proves the bearer was
|
||||
* identified as `auth_id` by the rejected session. The GUI
|
||||
* must echo this back (not the raw `auth_id`) on the next
|
||||
* identified as `auth_id` by the rejected session. The GUI must
|
||||
* echo this back (not the raw `auth_id`) on the next
|
||||
* login/signup so the controller can rebind to the same user.
|
||||
*/
|
||||
reauth_token?: string;
|
||||
@@ -70,7 +78,10 @@ declare global {
|
||||
device: { vendor?: string; model?: string; type?: string };
|
||||
};
|
||||
|
||||
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
|
||||
/**
|
||||
* True when the request's Host is a custom domain (not one of the
|
||||
* configured Puter domains).
|
||||
*/
|
||||
is_custom_domain?: boolean;
|
||||
|
||||
/**
|
||||
@@ -83,12 +94,15 @@ declare global {
|
||||
/**
|
||||
* Client-supplied device fingerprint (ThumbmarkJS hash) from the
|
||||
* body or `x-puter-device-fingerprint` header, populated by the
|
||||
* global fingerprint middleware. Present only when the client sent a
|
||||
* well-shaped value; spoofable but stable per device across IPs.
|
||||
* global fingerprint middleware. Present only when the client sent
|
||||
* a well-shaped value; spoofable but stable per device across IPs.
|
||||
*/
|
||||
deviceFingerprint?: string;
|
||||
|
||||
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
|
||||
/**
|
||||
* Parsed cookies, populated by the global `cookie-parser`
|
||||
* middleware.
|
||||
*/
|
||||
cookies?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ export {
|
||||
subdomainGate,
|
||||
} from './middleware/gates';
|
||||
export { createNotFoundHandler } from './middleware/notFoundHandler';
|
||||
export {
|
||||
createStepUpGate,
|
||||
signStepUpToken,
|
||||
STEP_UP_COOKIE_NAME,
|
||||
STEP_UP_PURPOSE,
|
||||
STEP_UP_SCOPE,
|
||||
STEP_UP_TTL_SECONDS,
|
||||
stepUpCookieOptions,
|
||||
verifyStepUpSession,
|
||||
} from './middleware/stepUpSession';
|
||||
export { PuterRouter } from './PuterRouter';
|
||||
export {
|
||||
PREFIX_METADATA_KEY,
|
||||
|
||||
@@ -262,6 +262,46 @@ describe('createAuthProbe — token extraction precedence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthProbe — tokenSource', () => {
|
||||
const cases: Array<[string, Parameters<typeof makeReq>[0]]> = [
|
||||
['body', { body: { auth_token: 'tok' } }],
|
||||
['header', { headers: { authorization: 'Bearer tok' } }],
|
||||
['x-api-key', { headers: { 'x-api-key': 'tok' } }],
|
||||
['cookie', { cookieHeader: 'puter_token=tok' }],
|
||||
['query', { query: { auth_token: 'tok' } }],
|
||||
['handshake', { handshakeQuery: { auth_token: 'tok' } }],
|
||||
];
|
||||
|
||||
const actor: Actor = { user: { uuid: 'u-1' } };
|
||||
|
||||
it.each(cases)('records %s', async (expected, init) => {
|
||||
const stub = makeStubAuth(actor);
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
const { req } = await runProbe(probe, makeReq(init));
|
||||
expect(req.tokenSource).toBe(expected);
|
||||
});
|
||||
|
||||
it('is left unset when no token is presented', async () => {
|
||||
const stub = makeStubAuth(actor);
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(probe, makeReq({}));
|
||||
expect(req.tokenSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is left unset when the token fails to authenticate', async () => {
|
||||
const stub = makeStubAuth(null);
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.tokenSource).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthProbe — header parsing', () => {
|
||||
it("strips 'Bearer ' (case-insensitive) from the Authorization value", async () => {
|
||||
const stub = makeStubAuth();
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
AuthService,
|
||||
ReauthReason,
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { TokenSource } from '../types';
|
||||
|
||||
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
|
||||
// wherever this middleware is imported.
|
||||
@@ -29,27 +31,31 @@ import '../expressAugmentation';
|
||||
|
||||
interface AuthProbeOptions {
|
||||
authService: AuthService;
|
||||
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
|
||||
/**
|
||||
* Name of the session cookie to inspect. Falls back to
|
||||
* `config.cookie_name`.
|
||||
*/
|
||||
cookieName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
|
||||
* every request, tries to locate a token in the usual places, and — if one
|
||||
* is present and valid — attaches an `Actor` to `req.actor`.
|
||||
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on every
|
||||
* request, tries to locate a token in the usual places, and — if one is present
|
||||
* and valid — attaches an `Actor` to `req.actor`.
|
||||
*
|
||||
* Key property: this middleware **never rejects**. Missing tokens, malformed
|
||||
* tokens, expired tokens, tokens pointing at deleted users — all result in
|
||||
* `req.actor` being left undefined. Per-route gates decide whether absence
|
||||
* is acceptable.
|
||||
* `req.actor` being left undefined. Per-route gates decide whether absence is
|
||||
* acceptable.
|
||||
*
|
||||
* Token lookup order:
|
||||
* 1. `req.body.auth_token`
|
||||
* 2. `Authorization: Bearer <token>` header
|
||||
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
|
||||
* 4. Session cookie, only when the browser request is same-origin
|
||||
* 5. `?auth_token=...` query param
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*
|
||||
* 1. `req.body.auth_token`
|
||||
* 2. `Authorization: Bearer <token>` header
|
||||
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
|
||||
* 4. Session cookie, only when the browser request is same-origin
|
||||
* 5. `?auth_token=...` query param
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
const { authService, cookieName } = opts;
|
||||
@@ -60,11 +66,12 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = extractToken(req, cookieName);
|
||||
if (!token) {
|
||||
const extracted = extractToken(req, cookieName);
|
||||
if (!extracted) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const { token, source } = extracted;
|
||||
|
||||
try {
|
||||
// Thread the request IP and User-Agent into authenticate so
|
||||
@@ -103,6 +110,7 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
if (result.actor) {
|
||||
req.actor = result.actor;
|
||||
req.token = token;
|
||||
req.tokenSource = source;
|
||||
} else if (result.invalid) {
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
@@ -117,12 +125,15 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
* Token extraction logic covering the request sources clients use to
|
||||
* authenticate.
|
||||
*/
|
||||
const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
const extractToken = (
|
||||
req: Request,
|
||||
cookieName?: string,
|
||||
): { token: string; source: TokenSource } | null => {
|
||||
// 1. Body (`{ "auth_token": "..." }`)
|
||||
const bodyToken = (req.body as { auth_token?: unknown } | undefined)
|
||||
?.auth_token;
|
||||
if (typeof bodyToken === 'string' && bodyToken.length > 0) {
|
||||
return stripBearer(bodyToken);
|
||||
return { token: stripBearer(bodyToken), source: 'body' };
|
||||
}
|
||||
|
||||
// 2. Authorization header. Reject `Basic ...` (not our scheme) and
|
||||
@@ -138,7 +149,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
) {
|
||||
const stripped = authHeader.replace(/^Bearer\s+/i, '').trim();
|
||||
if (stripped.length > 0 && stripped !== 'undefined') {
|
||||
return stripped;
|
||||
return { token: stripped, source: 'header' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +159,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
const xApiKey =
|
||||
typeof req.header === 'function' ? req.header('x-api-key') : undefined;
|
||||
if (typeof xApiKey === 'string' && xApiKey.length > 0) {
|
||||
return stripBearer(xApiKey);
|
||||
return { token: stripBearer(xApiKey), source: 'x-api-key' };
|
||||
}
|
||||
|
||||
// 4. Cookie (set by login flow for session tokens). Do not let an
|
||||
@@ -165,12 +176,12 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
if (cookieName) {
|
||||
const cookieToken = req.cookies?.[cookieName];
|
||||
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
|
||||
return stripBearer(cookieToken);
|
||||
return { token: stripBearer(cookieToken), source: 'cookie' };
|
||||
}
|
||||
}
|
||||
const v2Token = req.cookies?.puter_token_v2;
|
||||
if (typeof v2Token === 'string' && v2Token.length > 0) {
|
||||
return stripBearer(v2Token);
|
||||
return { token: stripBearer(v2Token), source: 'cookie' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +189,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
const queryToken = (req.query as { auth_token?: unknown } | undefined)
|
||||
?.auth_token;
|
||||
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
||||
return stripBearer(queryToken);
|
||||
return { token: stripBearer(queryToken), source: 'query' };
|
||||
}
|
||||
|
||||
// 6. Socket handshake (for websocket upgrades that pass through HTTP).
|
||||
@@ -187,7 +198,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
).handshake;
|
||||
const handshakeToken = handshake?.query?.auth_token;
|
||||
if (typeof handshakeToken === 'string' && handshakeToken.length > 0) {
|
||||
return stripBearer(handshakeToken);
|
||||
return { token: stripBearer(handshakeToken), source: 'handshake' };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -232,3 +232,50 @@ describe('createErrorHandler — when the response has already started streaming
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Database load-shed translation ──────────────────────────────────
|
||||
|
||||
describe('createErrorHandler — dbBatchFailed load-shed errors', () => {
|
||||
const makeDbBatchError = (reason: string) => {
|
||||
const err = new Error('Database operation failed') as Error & {
|
||||
code: string;
|
||||
reason: string;
|
||||
};
|
||||
err.code = 'dbBatchFailed';
|
||||
err.reason = reason;
|
||||
return err;
|
||||
};
|
||||
|
||||
it('maps to 503 + Retry-After instead of a generic 500', () => {
|
||||
const onUnhandled = vi.fn();
|
||||
const handler = createErrorHandler({ onUnhandled });
|
||||
const { out } = runHandler(handler, makeDbBatchError('breakerOpen'));
|
||||
|
||||
expect(out.statusCode).toBe(503);
|
||||
expect(out.headers['Retry-After']).toBe(5);
|
||||
expect(out.body).toMatchObject({
|
||||
code: 'db_unavailable',
|
||||
error: 'Service temporarily unavailable',
|
||||
});
|
||||
// Translated errors are expected degradation, not unhandled bugs.
|
||||
expect(onUnhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('translates every load-shed reason the batcher emits', () => {
|
||||
const handler = createErrorHandler({ onUnhandled: () => {} });
|
||||
for (const reason of ['breakerOpen', 'queueOverflow', 'connAcquire']) {
|
||||
const { out } = runHandler(handler, makeDbBatchError(reason));
|
||||
expect(out.statusCode).toBe(503);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves unrelated coded errors on the generic 500 path', () => {
|
||||
const onUnhandled = vi.fn();
|
||||
const handler = createErrorHandler({ onUnhandled });
|
||||
const err = new Error('boom') as Error & { code: string };
|
||||
err.code = 'somethingElse';
|
||||
const { out } = runHandler(handler, err);
|
||||
expect(out.statusCode).toBe(500);
|
||||
expect(onUnhandled).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,17 @@ export const createErrorHandler = (
|
||||
err = translated;
|
||||
}
|
||||
|
||||
// Database-batcher load-shed (circuit open, queue overflow, or no
|
||||
// connection available): the persistence layer is temporarily
|
||||
// degraded, not a programming bug. Surface as 503 so clients back
|
||||
// off and retry instead of treating it as a hard failure.
|
||||
if ((err as { code?: string } | null)?.code === 'dbBatchFailed') {
|
||||
res.setHeader('Retry-After', 5);
|
||||
err = new HttpError(503, 'Service temporarily unavailable', {
|
||||
code: 'db_unavailable',
|
||||
});
|
||||
}
|
||||
|
||||
if (isHttpError(err)) {
|
||||
opts.onError?.(err, req);
|
||||
if (err.statusCode === 402 || err.statusCode === 413) {
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
DEFAULT_ADMIN_USERNAMES,
|
||||
adminOnlyGate,
|
||||
allowedAppIdsGate,
|
||||
assertNotUserSession,
|
||||
noUserSessionGate,
|
||||
requireAuthGate,
|
||||
requireVerifiedAccount,
|
||||
requireNonAccessTokenGate,
|
||||
@@ -331,6 +333,103 @@ describe('requireNonAccessTokenGate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── noUserSessionGate ───────────────────────────────────────────────
|
||||
|
||||
describe('noUserSessionGate', () => {
|
||||
it('rejects a bare user-session ("root" token) actor with 403', () => {
|
||||
const got = runGate(noUserSessionGate(), {
|
||||
actor: { user: { uuid: 'u-1' } },
|
||||
});
|
||||
expectHttpError(got, 403, 'app_or_api_token_required');
|
||||
});
|
||||
|
||||
it('passes through for app-under-user actors (app/worker tokens)', () => {
|
||||
const got = runGate(noUserSessionGate(), {
|
||||
actor: { user: { uuid: 'u-1' }, app: { uid: 'app-1' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes through for user-scoped worker sessions (kind="worker")', () => {
|
||||
const got = runGate(noUserSessionGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1' },
|
||||
session: { uid: 'sess-1', kind: 'worker' },
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes through for access-token actors (which access tokens are OK is decided by the other gates)', () => {
|
||||
const got = runGate(noUserSessionGate(), {
|
||||
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(noUserSessionGate(), {});
|
||||
expectHttpError(got, 401, 'token_missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNotUserSession', () => {
|
||||
it('is a no-op for a missing actor (anonymous is the auth gate’s job)', () => {
|
||||
expect(() => assertNotUserSession(undefined)).not.toThrow();
|
||||
expect(() => assertNotUserSession(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('always admits a user-scoped worker session — workers are never root tokens', () => {
|
||||
// Workers deployed with no app binding hold a session-TYPE token
|
||||
// whose row is kind='worker' (see AuthService.createWorkerSessionToken)
|
||||
// — a bare user actor plus that session ref. The gate is an
|
||||
// annoyance for sign-up-and-scrape abuse; a deployed worker is
|
||||
// already a delegated, revocable credential.
|
||||
expect(() =>
|
||||
assertNotUserSession({
|
||||
app: null,
|
||||
accessToken: null,
|
||||
session: { uid: 'sess-1', kind: 'worker' },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('still rejects web sessions', () => {
|
||||
expect(() =>
|
||||
assertNotUserSession({
|
||||
app: null,
|
||||
accessToken: null,
|
||||
session: { uid: 'sess-1', kind: 'web' },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('throws 403 app_or_api_token_required for a bare session actor', () => {
|
||||
try {
|
||||
assertNotUserSession({ app: null, accessToken: null });
|
||||
expect.unreachable('expected assertNotUserSession to throw');
|
||||
} catch (err) {
|
||||
expect(isHttpError(err)).toBe(true);
|
||||
expect((err as HttpError).statusCode).toBe(403);
|
||||
expect((err as HttpError).legacyCode).toBe(
|
||||
'app_or_api_token_required',
|
||||
);
|
||||
// The user asked for a helpful message: it must point at the
|
||||
// credentials that DO work and where to get one.
|
||||
expect((err as HttpError).message).toMatch(/app or worker token/i);
|
||||
expect((err as HttpError).message).toMatch(/API token/);
|
||||
expect((err as HttpError).message).toMatch(/dashboard/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminOnlyGate ───────────────────────────────────────────────────
|
||||
|
||||
describe('adminOnlyGate', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { effectiveActorApp } from '../../actor';
|
||||
import type { Actor } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
|
||||
@@ -148,6 +149,59 @@ export const requireUserActorGate = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors — the "root" credential a browser session
|
||||
* (or `/login`) holds, with no app and no access token in play. Use on API
|
||||
* surfaces that must only be driven by a delegated credential: an app or
|
||||
* worker token, or an API token minted from the dashboard. The point is that
|
||||
* a leaked-or-copied session token (full account control) shouldn't double
|
||||
* as an AI/API credential; users are pushed to mint a revocable token
|
||||
* instead.
|
||||
*
|
||||
* This gate only rejects the bare-session shape. Which delegated credentials
|
||||
* are acceptable is decided by the gates it composes with (`requireUserActor`
|
||||
* + `allowFullAccessToken` to also keep apps out, `requireNonAccessTokenGate`
|
||||
* for scoped tokens, etc.).
|
||||
*/
|
||||
export const assertNotUserSession = (
|
||||
actor: Pick<Actor, 'app' | 'accessToken' | 'session'> | null | undefined,
|
||||
): void => {
|
||||
if (!actor) return; // anonymous requests are the auth gate's problem
|
||||
if (actor.app || actor.accessToken) return;
|
||||
// User-scoped workers (deployed with no app binding) authenticate with
|
||||
// a session-TYPE token whose session row is `kind='worker'` — a managed,
|
||||
// revocable deployment credential, not a browser sign-in. Workers are
|
||||
// never treated as root tokens: this gate is an annoyance for
|
||||
// sign-up-and-scrape abuse, and someone who deploys a worker to reach
|
||||
// an API has already left that path.
|
||||
if (actor.session?.kind === 'worker') return;
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This API cannot be called with an account session token. ' +
|
||||
'Use an app or worker token, or create an API token from the ' +
|
||||
'dashboard (Account → API Token).',
|
||||
{ legacyCode: 'app_or_api_token_required' },
|
||||
);
|
||||
};
|
||||
|
||||
/** Route-option form of {@link assertNotUserSession} (`noUserSession: true`). */
|
||||
export const noUserSessionGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
assertNotUserSession(actor);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
|
||||
@@ -22,25 +22,102 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '../HttpError';
|
||||
import { createNotFoundHandler } from './notFoundHandler';
|
||||
|
||||
const makeReq = (over: Partial<Request> = {}): Request =>
|
||||
({
|
||||
method: 'GET',
|
||||
hostname: 'puter.example',
|
||||
path: '/no-such-page',
|
||||
...over,
|
||||
}) as Request;
|
||||
|
||||
const makeRes = () => ({
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
redirect: vi.fn(),
|
||||
});
|
||||
|
||||
const expect404 = (next: ReturnType<typeof vi.fn>) => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
const err = next.mock.calls[0][0];
|
||||
expect(isHttpError(err)).toBe(true);
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.legacyCode).toBe('not_found');
|
||||
};
|
||||
|
||||
describe('createNotFoundHandler', () => {
|
||||
it("forwards an HttpError(404, 'not_found') to next() — does not write the response itself", () => {
|
||||
// The handler must NOT call res.json/status — that's the error
|
||||
// handler's job, so every failure goes through the same serializer.
|
||||
const handler = createNotFoundHandler();
|
||||
const next = vi.fn();
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as Response;
|
||||
handler({} as Request, res, next);
|
||||
const res = makeRes();
|
||||
handler(makeReq(), res as unknown as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
const err = next.mock.calls[0][0];
|
||||
expect(isHttpError(err)).toBe(true);
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.legacyCode).toBe('not_found');
|
||||
expect404(next);
|
||||
// Never wrote a response directly.
|
||||
expect((res.status as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
expect((res.json as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('with guiDomain set', () => {
|
||||
const handler = createNotFoundHandler({ guiDomain: 'puter.example' });
|
||||
|
||||
it('redirects an unmatched GET on the GUI domain to /', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
handler(makeReq(), res as unknown as Response, next);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects HEAD like GET', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
handler(
|
||||
makeReq({ method: 'HEAD' }),
|
||||
res as unknown as Response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(res.redirect).toHaveBeenCalledWith('/');
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still 404s non-GET methods on the GUI domain', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
handler(
|
||||
makeReq({ method: 'POST' }),
|
||||
res as unknown as Response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect404(next);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still 404s on subdomains (api., etc.)', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
handler(
|
||||
makeReq({ hostname: 'api.puter.example' }),
|
||||
res as unknown as Response,
|
||||
next,
|
||||
);
|
||||
|
||||
expect404(next);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never redirects / to itself', () => {
|
||||
const next = vi.fn();
|
||||
const res = makeRes();
|
||||
handler(makeReq({ path: '/' }), res as unknown as Response, next);
|
||||
|
||||
expect404(next);
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,16 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
export interface NotFoundHandlerOptions {
|
||||
/**
|
||||
* The bare GUI domain (`config.domain`). When set, unmatched GET/HEAD
|
||||
* requests whose host is exactly this domain redirect to `/` instead of
|
||||
* 404ing, so a typo'd or stale URL lands back on the desktop. Subdomains
|
||||
* (api., etc.) and custom domains are unaffected and still 404.
|
||||
*/
|
||||
guiDomain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all 404 middleware. Install last (just before the error handler);
|
||||
* any request that didn't match a route lands here.
|
||||
@@ -28,8 +38,22 @@ import { HttpError } from '../HttpError';
|
||||
* the same error-handler pipeline serializes the body — keeps the wire shape
|
||||
* consistent with every other failure (`{ error: '...', code: 'not_found' }`).
|
||||
*/
|
||||
export const createNotFoundHandler = (): RequestHandler => {
|
||||
return (_req, _res, next): void => {
|
||||
export const createNotFoundHandler = (
|
||||
opts: NotFoundHandlerOptions = {},
|
||||
): RequestHandler => {
|
||||
const guiDomain = opts.guiDomain?.trim().toLowerCase() || null;
|
||||
return (req, res, next): void => {
|
||||
if (
|
||||
guiDomain &&
|
||||
(req.method === 'GET' || req.method === 'HEAD') &&
|
||||
req.hostname?.toLowerCase() === guiDomain &&
|
||||
// '/' always matches the shell route; the guard just makes a
|
||||
// misconfigured deployment 404 instead of redirect-looping.
|
||||
req.path !== '/'
|
||||
) {
|
||||
res.redirect('/');
|
||||
return;
|
||||
}
|
||||
next(new HttpError(404, 'Not Found', { legacyCode: 'not_found' }));
|
||||
};
|
||||
};
|
||||
@@ -331,6 +331,27 @@ describe('buildPrivateHostRedirect', () => {
|
||||
expect(url).toBe('http://beans.app.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
|
||||
for (const evil of [
|
||||
'//evil.com/',
|
||||
'///evil.com/',
|
||||
'/\\evil.com/',
|
||||
'/\\/evil.com/',
|
||||
]) {
|
||||
const url = buildPrivateHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.site.puter.localhost',
|
||||
originalUrl: evil,
|
||||
}),
|
||||
{ name: 'beans' },
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.app.puter.localhost:4100/evil.com/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no private hosting domain is configured', () => {
|
||||
const noPrivate = {
|
||||
...cfg,
|
||||
@@ -400,6 +421,26 @@ describe('buildPublicHostRedirect', () => {
|
||||
expect(url).toBe('http://beans.site.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('collapses a scheme-relative path so it cannot escape the host (open redirect)', () => {
|
||||
for (const evil of [
|
||||
'//evil.com/',
|
||||
'///evil.com/',
|
||||
'/\\evil.com/',
|
||||
'/\\/evil.com/',
|
||||
]) {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.app.puter.localhost',
|
||||
originalUrl: evil,
|
||||
}),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.site.puter.localhost:4100/evil.com/',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null when no public hosting domain is configured', () => {
|
||||
const noPublic = {
|
||||
...cfg,
|
||||
|
||||
@@ -584,6 +584,17 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
|
||||
// -- Redirect helpers ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Turn a request path into a safe reference for `new URL(path, base)`.
|
||||
* Collapses any run of leading slashes/backslashes into a single `/` so a
|
||||
* scheme-relative path (`//evil.com`, `/\evil.com`, which the URL parser
|
||||
* folds to `//`) can't override the base authority and turn the redirect
|
||||
* into an open redirect. Guarantees exactly one leading slash.
|
||||
*/
|
||||
function normalizeRedirectPath(originalUrl: string | undefined): string {
|
||||
return '/' + (originalUrl || '/').replace(/^[/\\]+/, '');
|
||||
}
|
||||
|
||||
/** Build the URL to redirect a private-app request to its private host. */
|
||||
export function buildPrivateHostRedirect(
|
||||
req: Request,
|
||||
@@ -605,9 +616,7 @@ export function buildPrivateHostRedirect(
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${privateDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
const reqPath = normalizeRedirectPath(req.originalUrl);
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
@@ -638,9 +647,7 @@ export function buildPublicHostRedirect(
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${publicDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
const reqPath = normalizeRedirectPath(req.originalUrl);
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { TokenService } from '../../../services/auth/TokenService.js';
|
||||
import {
|
||||
createStepUpGate,
|
||||
signStepUpToken,
|
||||
STEP_UP_COOKIE_NAME,
|
||||
verifyStepUpSession,
|
||||
} from './stepUpSession.js';
|
||||
|
||||
const V2_SECRET = 'test-v2-secret';
|
||||
const USER_UUID = 'a1111111-1111-1111-1111-111111111111';
|
||||
|
||||
function tokenService(): TokenService {
|
||||
const config = {
|
||||
jwt_secret: 'test-v1-secret',
|
||||
jwt_secret_v2: V2_SECRET,
|
||||
allow_v1_tokens: true,
|
||||
} as ConstructorParameters<typeof TokenService>[0];
|
||||
const svc = new TokenService(
|
||||
config,
|
||||
{} as ConstructorParameters<typeof TokenService>[1],
|
||||
{} as ConstructorParameters<typeof TokenService>[2],
|
||||
{} as ConstructorParameters<typeof TokenService>[3],
|
||||
);
|
||||
svc.onServerStart();
|
||||
return svc;
|
||||
}
|
||||
|
||||
function reqWith(
|
||||
cookie: string | undefined,
|
||||
actorUuid: string | undefined,
|
||||
extra: Partial<Request> = {},
|
||||
): Request {
|
||||
return {
|
||||
cookies: cookie ? { [STEP_UP_COOKIE_NAME]: cookie } : {},
|
||||
actor: actorUuid ? { user: { uuid: actorUuid } } : undefined,
|
||||
...extra,
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('verifyStepUpSession', () => {
|
||||
it('accepts a token bound to the acting user', () => {
|
||||
const ts = tokenService();
|
||||
const token = signStepUpToken(ts, { uuid: USER_UUID });
|
||||
expect(
|
||||
verifyStepUpSession(reqWith(token, USER_UUID), { tokenService: ts }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a token bound to a different user (cookie alone is useless)', () => {
|
||||
const ts = tokenService();
|
||||
const token = signStepUpToken(ts, { uuid: USER_UUID });
|
||||
expect(
|
||||
verifyStepUpSession(
|
||||
reqWith(token, 'b2222222-2222-2222-2222-222222222222'),
|
||||
{ tokenService: ts },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when there is no actor (no live session)', () => {
|
||||
const ts = tokenService();
|
||||
const token = signStepUpToken(ts, { uuid: USER_UUID });
|
||||
expect(
|
||||
verifyStepUpSession(reqWith(token, undefined), {
|
||||
tokenService: ts,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the cookie is missing', () => {
|
||||
const ts = tokenService();
|
||||
expect(
|
||||
verifyStepUpSession(reqWith(undefined, USER_UUID), {
|
||||
tokenService: ts,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an expired token', () => {
|
||||
const ts = tokenService();
|
||||
// Sign with a lifetime past the verifier's 30s clock tolerance.
|
||||
const expired = ts.sign(
|
||||
'step-up',
|
||||
{ user_uuid: USER_UUID, purpose: 'elevation' },
|
||||
{ expiresIn: -60 },
|
||||
);
|
||||
expect(
|
||||
verifyStepUpSession(reqWith(expired, USER_UUID), {
|
||||
tokenService: ts,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token minted under a different scope/purpose (no cross-use)', () => {
|
||||
const ts = tokenService();
|
||||
// A well-formed session-style token must not satisfy the gate.
|
||||
const authToken = ts.sign('auth', {
|
||||
type: 'session',
|
||||
version: '2',
|
||||
user_uid: USER_UUID,
|
||||
});
|
||||
expect(
|
||||
verifyStepUpSession(reqWith(authToken, USER_UUID), {
|
||||
tokenService: ts,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStepUpGate', () => {
|
||||
it('passes a session with a valid elevation cookie', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const token = signStepUpToken(ts, { uuid: USER_UUID });
|
||||
const next = vi.fn();
|
||||
gate(reqWith(token, USER_UUID), {} as Response, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects a session without an elevation cookie, hinting the factor', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID, otp_enabled: true } },
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
const err = next.mock.calls[0][0];
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.legacyCode).toBe('elevation_required');
|
||||
expect(err.fields.factor).toBe('otp');
|
||||
});
|
||||
|
||||
it('hints the password factor when 2FA is off', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
gate(reqWith(undefined, USER_UUID), {} as Response, next);
|
||||
expect(next.mock.calls[0][0].fields.factor).toBe('password');
|
||||
});
|
||||
|
||||
// A stolen session can mint a full-access token via
|
||||
// /auth/create-access-token without re-proving identity, so exempting one
|
||||
// here would be a way around the gate rather than an exception to it.
|
||||
it('does NOT exempt full-access personal access tokens', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: {
|
||||
user: { uuid: USER_UUID },
|
||||
accessToken: { fullAccess: true },
|
||||
},
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
// App-gated routes (adminOnly + allowedAppIds): an admin acting through an
|
||||
// allowlisted app can't elevate — apps have no password/TOTP and are blocked
|
||||
// from /auth/elevate. The exemption keys off the token carrying an allowed
|
||||
// app id, not the route flag.
|
||||
it('exempts a token that carries an allowlisted app id', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({
|
||||
tokenService: ts,
|
||||
allowedAppUids: ['app-xyz'],
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('still requires step-up for an app id NOT in the allowlist', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({
|
||||
tokenService: ts,
|
||||
allowedAppUids: ['other-app'],
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('still requires step-up when the route has no allowedAppIds', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const next = vi.fn();
|
||||
const req = reqWith(undefined, USER_UUID, {
|
||||
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
|
||||
} as never);
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('still requires step-up on the human/root-token path (no app id in token)', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({
|
||||
tokenService: ts,
|
||||
allowedAppUids: ['app-xyz'],
|
||||
});
|
||||
const next = vi.fn();
|
||||
gate(reqWith(undefined, USER_UUID), {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts the elevation via the x-puter-elevation header (API clients)', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const token = signStepUpToken(ts, { uuid: USER_UUID });
|
||||
const next = vi.fn();
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { 'x-puter-elevation': token },
|
||||
actor: { user: { uuid: USER_UUID } },
|
||||
} as never;
|
||||
gate(req, {} as Response, next);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects a header elevation bound to a different user', () => {
|
||||
const ts = tokenService();
|
||||
const gate = createStepUpGate({ tokenService: ts });
|
||||
const token = signStepUpToken(ts, {
|
||||
uuid: 'b2222222-2222-2222-2222-222222222222',
|
||||
});
|
||||
const next = vi.fn();
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { 'x-puter-elevation': token },
|
||||
actor: { user: { uuid: USER_UUID } },
|
||||
} as never;
|
||||
gate(req, {} as Response, next);
|
||||
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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 type { IConfig } from '../../../types';
|
||||
import type { UserRow } from '../../../stores/user/UserStore';
|
||||
import type { TokenService } from '../../../services/auth/TokenService';
|
||||
import { sessionCookieFlags } from '../../../util/cookieFlags';
|
||||
import { HttpError } from '../HttpError';
|
||||
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
import '../expressAugmentation';
|
||||
|
||||
/**
|
||||
* Step-up ("elevation") sessions — a second factor layered on top of an
|
||||
* ordinary session for privileged endpoints (`adminOnly` routes).
|
||||
*
|
||||
* An ordinary session cookie proves only that someone holds the credential; for
|
||||
* privileged endpoints that isn't enough, since a leaked session would inherit
|
||||
* the privilege. Elevation makes the caller re-prove identity — a fresh TOTP
|
||||
* code when 2FA is enabled, otherwise the account password — via
|
||||
* `POST /auth/elevate`, which mints the cookie below.
|
||||
*
|
||||
* Both halves are required and neither is sufficient: gates demand a live
|
||||
* session actor AND this proof, and the proof is bound to that actor's
|
||||
* `user_uuid`. So a stolen session can't elevate itself, and a stolen elevation
|
||||
* proof is inert without the session.
|
||||
*
|
||||
* The proof travels as an httpOnly cookie for browsers, or as the
|
||||
* `x-puter-elevation` header for API clients (which have no cookie jar). The
|
||||
* two are equivalent — both are the same signed token, and obtaining either
|
||||
* requires the password/TOTP. Nothing is exempt from the requirement: there is
|
||||
* deliberately no carve-out for any credential kind, because any credential a
|
||||
* stolen session can obtain *without* re-proving identity would be a way around
|
||||
* this control rather than an exception to it.
|
||||
*
|
||||
* The token has its own scope and `purpose` claim and carries no auth `type`
|
||||
* claim, so `AuthService.authenticate` rejects it — it can never be spent as a
|
||||
* main auth token even though every scope shares `jwt_secret_v2`.
|
||||
*/
|
||||
|
||||
export const STEP_UP_COOKIE_NAME = 'puter_elevated';
|
||||
export const STEP_UP_HEADER_NAME = 'x-puter-elevation';
|
||||
export const STEP_UP_SCOPE = 'step-up';
|
||||
export const STEP_UP_PURPOSE = 'elevation';
|
||||
export const STEP_UP_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
interface StepUpPayload {
|
||||
user_uuid: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
/** Sign an elevation token bound to the user's uuid. */
|
||||
export function signStepUpToken(
|
||||
tokenService: TokenService,
|
||||
user: Pick<UserRow, 'uuid'>,
|
||||
): string {
|
||||
return tokenService.sign(
|
||||
STEP_UP_SCOPE,
|
||||
{ user_uuid: user.uuid, purpose: STEP_UP_PURPOSE },
|
||||
{ expiresIn: STEP_UP_TTL_SECONDS },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie flags for the elevation cookie. `domain` and `maxAge` are what
|
||||
* `sessionCookieFlags` doesn't set: the domain keeps the cookie readable across
|
||||
* the site's subdomains (privileged endpoints aren't all on one origin), and
|
||||
* `maxAge` gives the elevation its lifetime.
|
||||
*/
|
||||
export function stepUpCookieOptions(config: IConfig): {
|
||||
httpOnly: true;
|
||||
sameSite: 'none' | 'lax';
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
} {
|
||||
return {
|
||||
...sessionCookieFlags(config),
|
||||
httpOnly: true,
|
||||
maxAge: STEP_UP_TTL_SECONDS * 1000,
|
||||
...(config.domain ? { domain: config.domain } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff a valid elevation proof (cookie or `x-puter-elevation` header) is
|
||||
* present AND bound to the acting user. Never throws — a missing/expired/
|
||||
* mismatched proof returns false so callers can prompt for the second factor
|
||||
* instead of erroring.
|
||||
*/
|
||||
export function verifyStepUpSession(
|
||||
req: Request,
|
||||
deps: { tokenService: TokenService },
|
||||
): boolean {
|
||||
const header = req.headers?.[STEP_UP_HEADER_NAME];
|
||||
const token =
|
||||
req.cookies?.[STEP_UP_COOKIE_NAME] ??
|
||||
(typeof header === 'string' ? header : undefined);
|
||||
const actorUuid = req.actor?.user?.uuid;
|
||||
if (!token || !actorUuid) return false;
|
||||
try {
|
||||
const payload = deps.tokenService.verify<StepUpPayload>(
|
||||
STEP_UP_SCOPE,
|
||||
token,
|
||||
);
|
||||
return (
|
||||
payload?.purpose === STEP_UP_PURPOSE &&
|
||||
payload.user_uuid === actorUuid
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an elevated session. Runs after the privilege gate it supplements
|
||||
* (`adminOnlyGate`), so it only adds the re-authentication requirement.
|
||||
*
|
||||
* Narrow by design — the only exemption is the app-gated path:
|
||||
*
|
||||
* - Not env-conditional, so the flow exercised locally is the one that ships.
|
||||
* - No carve-out for full-access tokens. That looks safe (a deliberately
|
||||
* minted, header-borne credential) but isn't: `/auth/create-access-token`
|
||||
* needs only a session, so a stolen session can mint a full-access token
|
||||
* without ever re-proving identity and walk straight around this gate.
|
||||
* - No carve-out based on how the credential arrived (cookie vs bearer). The
|
||||
* holder of a token chooses which header to put it in, so that distinction
|
||||
* is attacker-controlled and worthless as a gate.
|
||||
* - `allowedAppUids`: the exemption is keyed off the *token*, not the route.
|
||||
* An actor whose token carries one of these allowlisted app ids (an admin
|
||||
* acting through an allowlisted app) is exempt — that actor can't elevate at
|
||||
* all (apps have no password/TOTP and are blocked from `/auth/elevate`), so
|
||||
* step-up is unsatisfiable for it. A token WITHOUT an allowlisted app id — a
|
||||
* root/human session — still requires step-up, exactly as it would on a
|
||||
* route with no `allowedAppIds`. So this is not a session or token-kind
|
||||
* carve-out: reaching the exempt path needs an admin's OAuth grant to a
|
||||
* specific allowlisted app.
|
||||
*
|
||||
* The invariant for the human path: reaching a privileged endpoint requires
|
||||
* proving the password or a TOTP code within the elevation's lifetime.
|
||||
*
|
||||
* A caller without a valid elevation proof is rejected with
|
||||
* `elevation_required`; `factor` tells the client which credential to collect.
|
||||
*/
|
||||
export function createStepUpGate(deps: {
|
||||
tokenService: TokenService;
|
||||
allowedAppUids?: readonly string[];
|
||||
}): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
// Exempt only an actor whose token carries one of the route's
|
||||
// allowlisted app ids: an admin acting through an allowlisted app can't
|
||||
// elevate, so step-up is unsatisfiable for it. Any other actor — most
|
||||
// importantly a root/human session with no app id in its token — falls
|
||||
// through and must present the elevation proof.
|
||||
const appUid = req.actor?.app?.uid;
|
||||
if (appUid && deps.allowedAppUids?.includes(appUid)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (verifyStepUpSession(req, deps)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(
|
||||
new HttpError(403, 'Re-authentication required', {
|
||||
legacyCode: 'elevation_required',
|
||||
fields: {
|
||||
code: 'elevation_required',
|
||||
factor: req.actor?.user?.otp_enabled ? 'otp' : 'password',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
+134
-93
@@ -3,23 +3,37 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import type { Actor } from '../actor';
|
||||
|
||||
/**
|
||||
* Which request slot the auth probe found its token in, recorded on
|
||||
* `req.tokenSource`. Routes that hand the browser durable credentials assert on
|
||||
* it instead of accepting any token that authenticates.
|
||||
*/
|
||||
export type TokenSource =
|
||||
| 'body'
|
||||
| 'header'
|
||||
| 'x-api-key'
|
||||
| 'cookie'
|
||||
| 'query'
|
||||
| 'handshake';
|
||||
|
||||
/**
|
||||
* Every route method PuterRouter exposes. Mirrors the express router surface
|
||||
* plus WebDAV verbs that some endpoints still use.
|
||||
@@ -47,7 +61,8 @@ export type RouteMethod =
|
||||
|
||||
/**
|
||||
* Path shape accepted by express route methods. Kept permissive rather than
|
||||
* re-exporting express's internal `PathParams` (which isn't stable public API).
|
||||
* re-exporting express's internal `PathParams` (which isn't stable public
|
||||
* API).
|
||||
*/
|
||||
export type RoutePath = string | RegExp | Array<string | RegExp>;
|
||||
|
||||
@@ -61,20 +76,20 @@ export type RoutePath = string | RegExp | Array<string | RegExp>;
|
||||
* requireUserActor → adminOnly → allowedAppIds →
|
||||
* caller `middleware: []` → handler
|
||||
*
|
||||
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply
|
||||
* `requireAuth`; the materializer dedupes so only one auth gate ends up
|
||||
* in the chain. Commented-out slots are reserved for the next chunks
|
||||
* (body parsing, post-auth gates, timing).
|
||||
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`;
|
||||
* the materializer dedupes so only one auth gate ends up 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.
|
||||
* 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);
|
||||
@@ -83,20 +98,26 @@ export interface RouteRateLimit {
|
||||
}
|
||||
|
||||
export interface RouteOptions {
|
||||
/** Extra per-route middleware. Applied after built-in gates, before the handler. */
|
||||
/**
|
||||
* Extra per-route middleware. Applied after built-in gates, before the
|
||||
* handler.
|
||||
*/
|
||||
middleware?: RequestHandler[];
|
||||
|
||||
/**
|
||||
* Subdomain routing. If set, the route only matches requests whose
|
||||
* leftmost subdomain is in this list (via `next('route')` skip).
|
||||
* Subdomain routing. If set, the route only matches requests whose leftmost
|
||||
* subdomain is in this list (via `next('route')` skip).
|
||||
*
|
||||
* If omitted, verb-routes (get/post/etc.) are restricted to the root
|
||||
* origin only (no subdomain). Pass `'*'` to explicitly match ANY
|
||||
* subdomain/root. `use()` middleware is not gated by default.
|
||||
* If omitted, verb-routes (get/post/etc.) are restricted to the root origin
|
||||
* only (no subdomain). Pass `'*'` to explicitly match ANY subdomain/root.
|
||||
* `use()` middleware is not gated by default.
|
||||
*/
|
||||
subdomain?: string | string[];
|
||||
|
||||
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
|
||||
/**
|
||||
* Reject anonymous + suspended-user requests with 401/403. Only allows user
|
||||
* and app actors
|
||||
*/
|
||||
requireAuth?: boolean;
|
||||
|
||||
/** Reject app/access-token actors. Implies `requireAuth`. */
|
||||
@@ -116,16 +137,29 @@ export interface RouteOptions {
|
||||
/** Allows access-tokens */
|
||||
allowAccessToken?: boolean;
|
||||
|
||||
/**
|
||||
* Reject bare user-session actors ("root" tokens — no app, no access token)
|
||||
* with 403 `app_or_api_token_required`. Implies `requireAuth`. Use on API
|
||||
* surfaces that must only be driven by a delegated credential (app/worker
|
||||
* token or a dashboard-minted API token), so a copied session token can't
|
||||
* double as an API credential. Compose with `requireUserActor` +
|
||||
* `allowFullAccessToken` to further narrow to API tokens only (apps stay
|
||||
* out too). Worker sessions (`session.kind === 'worker'`) always pass — a
|
||||
* deployed worker is a delegated, revocable credential, never a root
|
||||
* token.
|
||||
*/
|
||||
noUserSession?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's username is `admin`, `system`, or one of the
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
* to that pair (does not replace it). Implies `requireAuth`.
|
||||
*
|
||||
* Also requires a *root token* (an actor with no app anywhere in its
|
||||
* token chain), so an admin acting through a third-party app can't reach
|
||||
* the route. Pair with `allowedAppIds` to make an admin route reachable
|
||||
* by specific apps: the combination admits a root token OR a token
|
||||
* scoped to an allowed app.
|
||||
* Also requires a _root token_ (an actor with no app anywhere in its token
|
||||
* chain), so an admin acting through a third-party app can't reach the
|
||||
* route. Pair with `allowedAppIds` to make an admin route reachable by
|
||||
* specific apps: the combination admits a root token OR a token scoped to
|
||||
* an allowed app.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — a root token still includes an
|
||||
* admin's full-access personal access token, not only browser sessions.
|
||||
@@ -133,16 +167,19 @@ export interface RouteOptions {
|
||||
*/
|
||||
adminOnly?: boolean | string[];
|
||||
|
||||
/** Reject unless the actor is acting through one of these apps. Implies `requireAuth`. */
|
||||
/**
|
||||
* Reject unless the actor is acting through one of these apps. Implies
|
||||
* `requireAuth`.
|
||||
*/
|
||||
allowedAppIds?: string[];
|
||||
|
||||
/**
|
||||
* Allow users whose account is pending email confirmation to access
|
||||
* this route. By default, any authenticated route rejects users where
|
||||
* `requires_email_confirmation && !email_confirmed` with 403. Set
|
||||
* this to `true` on essential flows that must remain accessible
|
||||
* before confirmation: logout, email-confirm, whoami, save-account,
|
||||
* anti-CSRF token, etc.
|
||||
* Allow users whose account is pending email confirmation to access this
|
||||
* route. By default, any authenticated route rejects users where
|
||||
* `requires_email_confirmation && !email_confirmed` with 403. Set this to
|
||||
* `true` on essential flows that must remain accessible before
|
||||
* confirmation: logout, email-confirm, whoami, save-account, anti-CSRF
|
||||
* token, etc.
|
||||
*
|
||||
* Only meaningful when the route also requires authentication (via
|
||||
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
|
||||
@@ -155,8 +192,8 @@ export interface RouteOptions {
|
||||
* `config.strict_email_verification_required` is falsy, so self-hosted
|
||||
* deployments can opt in via config. Implies `requireAuth` but NOT
|
||||
* `requireUserActor` — app-under-user actors also carry a `.user`, so
|
||||
* verification applies uniformly whether the user acts directly or
|
||||
* through an app.
|
||||
* verification applies uniformly whether the user acts directly or through
|
||||
* an app.
|
||||
*/
|
||||
requireVerified?: boolean;
|
||||
|
||||
@@ -166,14 +203,15 @@ export interface RouteOptions {
|
||||
* the raw bytes on `req.rawBody` for signature-verification use cases.
|
||||
*
|
||||
* Use this option only when a route needs different parser settings:
|
||||
* - `false` — opt out of parsing entirely (rare; the route reads the
|
||||
* raw stream itself, e.g. some webhook proxies). The global parser
|
||||
* will still have already run if the content-type was JSON, so this
|
||||
* is mostly useful for routes that accept *non*-JSON body shapes
|
||||
* and want to ensure no further parsers attach.
|
||||
* - `{ limit, type }` — override the limit (e.g., for ML endpoints
|
||||
* that legitimately need 100mb) or the matched content-type list
|
||||
* (e.g., to ALSO accept `application/x-ndjson`).
|
||||
*
|
||||
* - `false` — opt out of parsing entirely (rare; the route reads the raw
|
||||
* stream itself, e.g. some webhook proxies). The global parser will still
|
||||
* have already run if the content-type was JSON, so this is mostly useful
|
||||
* for routes that accept _non_-JSON body shapes and want to ensure no
|
||||
* further parsers attach.
|
||||
* - `{ limit, type }` — override the limit (e.g., for ML endpoints that
|
||||
* legitimately need 100mb) or the matched content-type list (e.g., to
|
||||
* ALSO accept `application/x-ndjson`).
|
||||
*/
|
||||
bodyJson?: false | { limit?: string; type?: string | string[] };
|
||||
|
||||
@@ -198,66 +236,64 @@ export interface RouteOptions {
|
||||
bodyUrlencoded?: boolean | { limit?: string; extended?: boolean };
|
||||
|
||||
/**
|
||||
* Require captcha verification. When `true`, the route rejects
|
||||
* requests that don't carry valid `captchaToken` + `captchaAnswer`
|
||||
* fields in the body. No-op when captcha is disabled in config.
|
||||
* Require captcha verification. When `true`, the route rejects requests
|
||||
* that don't carry valid `captchaToken` + `captchaAnswer` fields in the
|
||||
* body. No-op when captcha is disabled in config.
|
||||
*/
|
||||
captcha?: boolean;
|
||||
|
||||
/**
|
||||
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`.
|
||||
* The token is consumed on use. Requires authentication (keyed by
|
||||
* user uuid).
|
||||
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`. The
|
||||
* token is consumed on use. Requires authentication (keyed by user uuid).
|
||||
*/
|
||||
antiCsrf?: boolean;
|
||||
|
||||
/**
|
||||
* Per-route rate limiting. In-memory sliding window keyed by
|
||||
* request identity.
|
||||
* Per-route rate limiting. In-memory sliding window keyed by request
|
||||
* identity.
|
||||
*
|
||||
* `key` controls how requests are bucketed:
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers),
|
||||
* refined by the client's device fingerprint when one was
|
||||
* supplied. Safe for shared IPs (offices, VPNs): each device
|
||||
* gets its own bucket instead of the whole network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes
|
||||
* where you want per-account limits.
|
||||
* - `(req) => string` — custom key function.
|
||||
*
|
||||
* `scope` is an optional namespace prefix to isolate counters
|
||||
* between routes that share the same key strategy. Defaults to
|
||||
* the route path.
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers), refined by the
|
||||
* client's device fingerprint when one was supplied. Safe for shared IPs
|
||||
* (offices, VPNs): each device gets its own bucket instead of the whole
|
||||
* network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes where you want
|
||||
* per-account limits.
|
||||
* - `(req) => string` — custom key function.
|
||||
*
|
||||
* `backend` selects the storage backend ('memory' / 'redis' / 'kv').
|
||||
* 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`).
|
||||
* `scope` is an optional namespace prefix to isolate counters between
|
||||
* routes that share the same key strategy. Defaults to the route path.
|
||||
*
|
||||
* 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.
|
||||
* `backend` selects the storage backend ('memory' / 'redis' / 'kv'). 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?: RouteRateLimit | RouteRateLimit[];
|
||||
|
||||
/**
|
||||
* Concurrent in-flight limiting. Caps how many requests for this
|
||||
* key are simultaneously in flight, rather than how many fire per
|
||||
* window. A slot is acquired before the handler runs and released
|
||||
* on `res.finish` / `res.close` — aborted requests still give their
|
||||
* slot back.
|
||||
* Concurrent in-flight limiting. Caps how many requests for this key are
|
||||
* simultaneously in flight, rather than how many fire per window. A slot is
|
||||
* acquired before the handler runs and released on `res.finish` /
|
||||
* `res.close` — aborted requests still give their slot back.
|
||||
*
|
||||
* { concurrent: { limit: 5, key: 'user' } }
|
||||
* { concurrent: { limit: 5, bySubscription: { user_free: 2 } } }
|
||||
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
|
||||
* bySubscription: { user_free: 2 } } }
|
||||
*
|
||||
* `bySubscription` overrides the base `limit` per subscription tier
|
||||
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited`
|
||||
* out of the box. Requires the metering service to be wired into
|
||||
* the rate-limit module (`configureRateLimit({ metering: ... })`)
|
||||
* and an authenticated actor; otherwise the base `limit` applies.
|
||||
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited` out of
|
||||
* the box. Requires the metering service to be wired into the rate-limit
|
||||
* module (`configureRateLimit({ metering: ... })`) and an authenticated
|
||||
* actor; otherwise the base `limit` applies.
|
||||
*
|
||||
* `key`, `scope`, `backend` parallel the `rateLimit` option exactly;
|
||||
* there's no `window` — that's the whole point of the second gate.
|
||||
@@ -273,11 +309,14 @@ export interface RouteOptions {
|
||||
// Reserved — wire as the corresponding features/services land:
|
||||
// bodyFiles?: string[]; // multer-style multipart fields
|
||||
// responseTimeout?: number;
|
||||
|
||||
realMime?: boolean; // for legacy FS controller, see `LegacyFSController#serveFile`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized route record produced by PuterRouter (and the class/method
|
||||
* decorators). `path` is omitted only for `router.use(handler)` / `use(options, handler)`.
|
||||
* decorators). `path` is omitted only for `router.use(handler)` / `use(options,
|
||||
* handler)`.
|
||||
*/
|
||||
export interface RouteDescriptor {
|
||||
method: RouteMethod;
|
||||
@@ -288,8 +327,8 @@ export interface RouteDescriptor {
|
||||
|
||||
/**
|
||||
* Shape stored on decorated controller prototypes by `@Get` / `@Post` / etc.
|
||||
* `handler` is the method reference — still unbound at decoration time;
|
||||
* the installed `registerRoutes` binds it to the instance at walk time.
|
||||
* `handler` is the method reference — still unbound at decoration time; the
|
||||
* installed `registerRoutes` binds it to the instance at walk time.
|
||||
*/
|
||||
export interface CollectedRoute {
|
||||
method: RouteMethod;
|
||||
@@ -317,8 +356,8 @@ export const PREFIX_METADATA_KEY = '__puterControllerPrefix' as const;
|
||||
|
||||
/**
|
||||
* `true` iff the materializer will run an auth gate before the handler.
|
||||
* Branches match readonly *and* mutable arrays so callers don't need
|
||||
* `as const` on every options literal.
|
||||
* Branches match readonly _and_ mutable arrays so callers don't need `as const`
|
||||
* on every options literal.
|
||||
*/
|
||||
export type AuthRequired<O extends RouteOptions> = O extends {
|
||||
requireAuth: true;
|
||||
@@ -330,7 +369,9 @@ export type AuthRequired<O extends RouteOptions> = O extends {
|
||||
? true
|
||||
: O extends { allowedAppIds: readonly string[] | string[] }
|
||||
? true
|
||||
: false;
|
||||
: O extends { noUserSession: true }
|
||||
? true
|
||||
: false;
|
||||
|
||||
/** Express `Request` with `actor` narrowed based on the route's options. */
|
||||
export type TypedRequest<O extends RouteOptions> = Omit<Request, 'actor'> & {
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
@@ -37,6 +38,7 @@ 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 { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
|
||||
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
|
||||
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
|
||||
@@ -72,13 +74,13 @@ type ProviderAttempt = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture what an upstream provider gave us so the classifier downstream
|
||||
* can decide a user-facing status code instead of always returning 500.
|
||||
* Capture what an upstream provider gave us so the classifier downstream can
|
||||
* decide a user-facing status code instead of always returning 500.
|
||||
*
|
||||
* OpenAI-SDK-based providers throw `APIError` with `.status` and a
|
||||
* structured `.error` body — pull both. For arbitrary errors we fall
|
||||
* back to the message and a status sniff so providers that throw plain
|
||||
* `Error("... 503 ...")` strings still classify correctly.
|
||||
* OpenAI-SDK-based providers throw `APIError` with `.status` and a structured
|
||||
* `.error` body — pull both. For arbitrary errors we fall back to the message
|
||||
* and a status sniff so providers that throw plain `Error("... 503 ...")`
|
||||
* strings still classify correctly.
|
||||
*/
|
||||
const toAttempt = (
|
||||
modelId: string,
|
||||
@@ -126,11 +128,12 @@ const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
* Map an exhausted fallback chain to a single user-facing HttpError.
|
||||
*
|
||||
* Per-class rules (see also alarm gate in server.ts):
|
||||
* - all rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
|
||||
* - all auth failures → 500 `upstream_auth_failed` (paged: our config)
|
||||
* - all upstream 5xx → 400 `upstream_provider_unavailable` (no page)
|
||||
* - all upstream 4xx (other) → 400 `upstream_bad_request` (no page)
|
||||
* - mixed → 400 `upstream_failed` (no page)
|
||||
*
|
||||
* - All rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
|
||||
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
|
||||
* - All upstream 5xx → 400 `upstream_provider_unavailable` (no page)
|
||||
* - All upstream 4xx (other) → 400 `upstream_bad_request` (no page)
|
||||
* - Mixed → 400 `upstream_failed` (no page)
|
||||
*/
|
||||
const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
|
||||
const fields = { attempts };
|
||||
@@ -196,10 +199,10 @@ const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
|
||||
/**
|
||||
* Driver implementing the `puter-chat-completion` interface.
|
||||
*
|
||||
* Manages multiple upstream providers (Claude, OpenAI, …) and handles
|
||||
* model resolution, provider routing, fallback on failure, and message
|
||||
* normalisation. Each provider is a plain `IChatProvider` — the driver
|
||||
* instantiates them from config on boot.
|
||||
* Manages multiple upstream providers (Claude, OpenAI, …) and handles model
|
||||
* resolution, provider routing, fallback on failure, and message normalisation.
|
||||
* Each provider is a plain `IChatProvider` — the driver instantiates them from
|
||||
* config on boot.
|
||||
*
|
||||
* Providers handle their own metering internally.
|
||||
*/
|
||||
@@ -985,6 +988,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const infron = providers['infron'];
|
||||
const infronKey = readKey(infron);
|
||||
if (infronKey) {
|
||||
this.#providers['infron'] = new InfronProvider(
|
||||
{
|
||||
apiKey: infronKey,
|
||||
apiBaseUrl: infron?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
}
|
||||
|
||||
// Fake provider — always available for testing
|
||||
this.#providers['fake-chat'] = new FakeChatProvider();
|
||||
}
|
||||
@@ -992,7 +1007,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// -- Model map ---------------------------------------------------
|
||||
|
||||
async #buildModelMap() {
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter']);
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter', 'infron']);
|
||||
|
||||
for (const providerName in this.#providers) {
|
||||
const provider = this.#providers[providerName];
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
@@ -271,6 +272,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-5',
|
||||
].includes(modelUsed.id);
|
||||
const resolvedTemperature = omitsTemperature
|
||||
? undefined
|
||||
@@ -280,6 +282,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
const supportsEffort = [
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
@@ -682,6 +685,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
// the stream.
|
||||
if (
|
||||
modelId === 'claude-fable-5' ||
|
||||
modelId === 'claude-opus-5' ||
|
||||
modelId === 'claude-opus-4-8' ||
|
||||
modelId === 'claude-opus-4-7'
|
||||
) {
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
@@ -79,6 +80,36 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
context: 1000000,
|
||||
max_tokens: 64000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-opus-5',
|
||||
id: 'claude-opus-5',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-05',
|
||||
release_date: '2026-07-24',
|
||||
aliases: [
|
||||
'claude-opus',
|
||||
'claude-opus-latest',
|
||||
'claude-opus-5-latest',
|
||||
'claude-opus-5',
|
||||
'anthropic/claude-opus-5',
|
||||
],
|
||||
name: 'Claude Opus 5',
|
||||
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-8',
|
||||
id: 'claude-opus-4-8',
|
||||
@@ -88,8 +119,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
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',
|
||||
@@ -338,127 +367,4 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
context: 200000,
|
||||
max_tokens: 32000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-sonnet-4',
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-03-31',
|
||||
release_date: '2025-05-22',
|
||||
aliases: [
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4-latest',
|
||||
'anthropic/claude-sonnet-4',
|
||||
],
|
||||
name: 'Claude Sonnet 4',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 200000,
|
||||
max_tokens: 64000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-3-7-sonnet',
|
||||
id: 'claude-3-7-sonnet-20250219',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10-31',
|
||||
release_date: '2025-02-19',
|
||||
aliases: ['claude-3-7-sonnet-latest', 'anthropic/claude-3-7-sonnet'],
|
||||
succeeded_by: 'claude-sonnet-4-20250514',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 200000,
|
||||
max_tokens: 8192,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-3-5-sonnet',
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-04-30',
|
||||
release_date: '2024-10-22',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
aliases: ['claude-3-5-sonnet-latest', 'anthropic/claude-3-5-sonnet'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 200000,
|
||||
max_tokens: 8192,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-3-5-sonnet-20240620',
|
||||
id: 'claude-3-5-sonnet-20240620',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-04-30',
|
||||
release_date: '2024-06-20',
|
||||
succeeded_by: 'claude-3-5-sonnet-20241022',
|
||||
aliases: ['anthropic/claude-3-5-sonnet-20240620'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 200000,
|
||||
max_tokens: 8192,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-3-haiku',
|
||||
id: 'claude-3-haiku-20240307',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2023-08-31',
|
||||
release_date: '2024-03-13',
|
||||
aliases: ['anthropic/claude-3-haiku'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 25,
|
||||
ephemeral_5m_input_tokens: 25 * 1.25,
|
||||
ephemeral_1h_input_tokens: 25 * 2,
|
||||
cache_read_input_tokens: 25 * 0.1,
|
||||
output_tokens: 125,
|
||||
},
|
||||
context: 200000,
|
||||
max_tokens: 4096,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 Infron aggregator.
|
||||
*
|
||||
* Routes through Infron to a tiny upstream model
|
||||
* (`deepseek/deepseek-v4-flash`). Skipped when
|
||||
* `PUTER_TEST_AI_INFRON_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { InfronProvider } from './InfronProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_INFRON_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))('InfronProvider (integration)', () => {
|
||||
it('returns a non-empty completion via Infron', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new InfronProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'Say hi in one word.' }],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } }).message
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for InfronProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs InfronProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. Infron is OpenAI-compatible, so the OpenAI SDK is mocked at
|
||||
* the module boundary; the model catalog is fetched via `axios`
|
||||
* which is mocked at its module boundary too. Both are the real
|
||||
* network egress points. Each test clears the kv-cached model list.
|
||||
* The companion integration test (InfronProvider.integration.test.ts)
|
||||
* exercises the real Infron endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { kv } from '../../../../util/kvSingleton.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { InfronProvider } from './InfronProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { createMock, openAICtor } = vi.hoisted(() => ({
|
||||
createMock: vi.fn(),
|
||||
openAICtor: vi.fn(),
|
||||
}));
|
||||
|
||||
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 } };
|
||||
});
|
||||
|
||||
// ── axios mock (model catalog endpoint) ─────────────────────────────
|
||||
|
||||
const { axiosRequestMock } = vi.hoisted(() => ({
|
||||
axiosRequestMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: { request: axiosRequestMock },
|
||||
request: axiosRequestMock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
const KV_KEY = 'infronChat:models';
|
||||
|
||||
// Prices are USD per million tokens (Infron catalog convention).
|
||||
const SAMPLE_API_MODELS = [
|
||||
{
|
||||
id: 'deepseek/deepseek-v4-flash',
|
||||
display_name: 'DeepSeek: DeepSeek V4 Flash',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 384000,
|
||||
min_prompt_price: 10,
|
||||
min_completion_price: 30,
|
||||
},
|
||||
{
|
||||
id: 'anthropic/claude-haiku-4.5',
|
||||
display_name: 'Anthropic: Claude Haiku 4.5',
|
||||
category_type: 'LLM',
|
||||
supported_endpoint_types: ['openai'],
|
||||
context_length: 200000,
|
||||
max_output_tokens: 8192,
|
||||
min_prompt_price: 2,
|
||||
min_completion_price: 10,
|
||||
},
|
||||
{
|
||||
// Non-chat modality — filtered out.
|
||||
id: 'black-forest-labs/flux-2.1',
|
||||
display_name: 'FLUX 2.1',
|
||||
category_type: 'Text to Image',
|
||||
supported_endpoint_types: ['openai'],
|
||||
min_request_price: 0.04,
|
||||
},
|
||||
{
|
||||
// Display-only entries are not callable — filtered out.
|
||||
id: 'example/display-only-model',
|
||||
display_name: 'Display Only',
|
||||
category_type: 'LLM',
|
||||
is_display_only: true,
|
||||
supported_endpoint_types: ['openai'],
|
||||
},
|
||||
];
|
||||
|
||||
const seedModelsCache = () =>
|
||||
axiosRequestMock.mockResolvedValue({ data: { data: SAMPLE_API_MODELS } });
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = () => {
|
||||
const provider = new InfronProvider(
|
||||
{ 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const makeCapturingChatStream = () => {
|
||||
const chunks: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
});
|
||||
const chatStream = new AIChatStream({ stream: sink });
|
||||
return {
|
||||
chatStream,
|
||||
events: () =>
|
||||
chunks
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
axiosRequestMock.mockReset();
|
||||
seedModelsCache();
|
||||
kv.del(KV_KEY);
|
||||
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
kv.del(KV_KEY);
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider construction', () => {
|
||||
it('points the OpenAI SDK at the Infron base URL with the configured key', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledTimes(1);
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://llm.onerouter.pro/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an apiBaseUrl override', () => {
|
||||
new InfronProvider(
|
||||
{
|
||||
apiKey: 'test-key',
|
||||
apiBaseUrl: 'https://custom.infron.example/v1',
|
||||
},
|
||||
server.services.metering,
|
||||
);
|
||||
expect(openAICtor).toHaveBeenLastCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://custom.infron.example/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider model catalog', () => {
|
||||
it('returns the infron-prefixed default model id', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe(
|
||||
'infron:deepseek/deepseek-v4-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the API key as a bearer token on the catalog fetch', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
const [args] = axiosRequestMock.mock.calls[0]!;
|
||||
expect(args.url).toBe('https://llm.onerouter.pro/v1/models');
|
||||
expect(args.headers).toMatchObject({
|
||||
Authorization: 'Bearer test-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('list() prefixes ids with infron: and filters non-chat and display-only entries', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
expect(ids).toContain('infron:deepseek/deepseek-v4-flash');
|
||||
expect(ids).toContain('infron:anthropic/claude-haiku-4.5');
|
||||
expect(ids).not.toContain('infron:black-forest-labs/flux-2.1');
|
||||
expect(ids).not.toContain('infron:example/display-only-model');
|
||||
});
|
||||
|
||||
it('caches the model list in kv after the first axios round-trip', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await provider.models();
|
||||
await provider.models();
|
||||
// Second call should be a cache hit, not a second axios request.
|
||||
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts USD-per-million-token prices to microcents per token', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
// $10/M tokens → 10 * 100 = 1000 microcents per token.
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'infron:deepseek/deepseek-v4-flash',
|
||||
costs: expect.objectContaining({
|
||||
tokens: 1_000_000,
|
||||
prompt: 1000,
|
||||
completion: 3000,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ──────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.complete request shape', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
it('strips the infron: prefix from the wire model id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
// infron: prefix is dropped before the SDK call.
|
||||
expect(args.model).toBe('deepseek/deepseek-v4-flash');
|
||||
// Infron requires `usage: { include: true }` to surface the
|
||||
// cost field — the provider always sets this.
|
||||
expect(args.usage).toEqual({ include: true });
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].stream).toBe(false);
|
||||
expect('stream_options' in createMock.mock.calls[0]![0]).toBe(false);
|
||||
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[1]![0].stream_options).toEqual({
|
||||
include_usage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion: cost calculator branches ─────────────────
|
||||
|
||||
describe('InfronProvider.complete non-stream output', () => {
|
||||
it('uses the cost-bearing branch when the top-level cost is present', 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 },
|
||||
},
|
||||
// Infron reports cost at the top level of the completion,
|
||||
// not inside `usage`.
|
||||
cost: 0.0001,
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
)) as { usage: Record<string, number> };
|
||||
|
||||
// The cost-bearing branch zeroes per-token costs and bills via a
|
||||
// single `billedUsage` line item priced at cost * 1e8.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
|
||||
expect(usage).toMatchObject({
|
||||
prompt: 100 - 10, // prompt_tokens - cached
|
||||
completion: 50,
|
||||
input_cache_read: 10,
|
||||
billedUsage: 1,
|
||||
});
|
||||
// All per-token costs are zeroed so Infron's authoritative
|
||||
// cost is the only thing that bills.
|
||||
expect(overrides.prompt).toBe(0);
|
||||
expect(overrides.completion).toBe(0);
|
||||
expect(overrides.input_cache_read).toBe(0);
|
||||
expect(overrides.billedUsage).toBe(0.0001 * 100_000_000);
|
||||
// The returned usage exposes usd_cents derived from cost.
|
||||
expect(result.usage.usd_cents).toBe(0.0001 * 100);
|
||||
});
|
||||
|
||||
it('falls back to per-token pricing when cost is absent', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
// No top-level `cost` → fallback branch.
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
// deepseek-v4-flash catalog pricing converted to microcents per
|
||||
// token: prompt=$10/M → 1000, completion=$30/M → 3000; cache
|
||||
// reads fall back to the full prompt rate.
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toMatchObject({
|
||||
prompt: 90,
|
||||
completion: 50,
|
||||
input_cache_read: 10,
|
||||
});
|
||||
expect(overrides.prompt).toBe(90 * 1000);
|
||||
expect(overrides.completion).toBe(50 * 3000);
|
||||
expect(overrides.input_cache_read).toBe(10 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.complete streaming', () => {
|
||||
it('streams text deltas through to text events and meters the final-chunk cost', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'hel' } }] },
|
||||
{ choices: [{ delta: { content: 'lo' } }] },
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
},
|
||||
// Cost rides at the top level of the final chunk.
|
||||
cost: 0.00005,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'infron:deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect((result as { stream: boolean }).stream).toBe(true);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const events = harness.events();
|
||||
const textEvents = events.filter((e) => e.type === 'text');
|
||||
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
|
||||
|
||||
// Cost-branch metering on the final chunk.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
|
||||
expect(overrides.billedUsage).toBe(0.00005 * 100_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('InfronProvider.checkModeration', () => {
|
||||
it('throws — Infron provider does not implement moderation', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(() => provider.checkModeration('anything')).toThrow(
|
||||
/not implemented/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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 axios from 'axios';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources';
|
||||
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 type {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
IChatCompleteResult,
|
||||
ICompleteArguments,
|
||||
} from '../../types.js';
|
||||
|
||||
/**
|
||||
* Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter
|
||||
* there is no `pricing` object; prices are USD per million tokens in
|
||||
* `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat
|
||||
* modalities (image, video, embeddings) that this provider filters out via
|
||||
* `category_type`.
|
||||
*/
|
||||
type InfronApiModel = {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
category_type?: string;
|
||||
is_display_only?: boolean;
|
||||
supported_endpoint_types?: string[];
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
min_prompt_price?: number;
|
||||
min_completion_price?: number;
|
||||
min_request_price?: number;
|
||||
};
|
||||
|
||||
type InfronUsage = OpenAI.Completions.CompletionUsage & {
|
||||
cost?: number;
|
||||
};
|
||||
|
||||
const KV_MODELS_KEY = 'infronChat:models';
|
||||
|
||||
export class InfronProvider implements IChatProvider {
|
||||
#meteringService: MeteringService;
|
||||
|
||||
#openai: OpenAI;
|
||||
|
||||
#apiKey: string;
|
||||
|
||||
#apiBaseUrl: string = 'https://llm.onerouter.pro/v1';
|
||||
|
||||
constructor(
|
||||
config: { apiBaseUrl?: string; apiKey: string },
|
||||
meteringService: MeteringService,
|
||||
) {
|
||||
this.#apiBaseUrl = config.apiBaseUrl || 'https://llm.onerouter.pro/v1';
|
||||
this.#apiKey = config.apiKey;
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: this.#apiBaseUrl,
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'infron:deepseek/deepseek-v4-flash';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of available model names
|
||||
*
|
||||
* @returns {Promise<string[]>} Array of model identifiers
|
||||
*/
|
||||
async list() {
|
||||
const models = await this.models();
|
||||
const model_names: string[] = [];
|
||||
for (const model of models) {
|
||||
model_names.push(model.id);
|
||||
}
|
||||
return model_names;
|
||||
}
|
||||
|
||||
/** AI Chat completion method. See AIChatService for more details. */
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
const availableModels = await this.models();
|
||||
const modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
const modelIdForParams = modelUsed.id.startsWith('infron:')
|
||||
? modelUsed.id.slice('infron:'.length)
|
||||
: modelUsed.id;
|
||||
|
||||
const actor = Context.get('actor');
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const completionParams = {
|
||||
messages,
|
||||
model: modelIdForParams,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens,
|
||||
temperature,
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
// Surfaces the authoritative `cost` field (USD) on the
|
||||
// response so metering doesn't depend on catalog prices.
|
||||
usage: { include: true },
|
||||
} as ChatCompletionCreateParams;
|
||||
|
||||
const completion =
|
||||
await this.#openai.chat.completions.create(completionParams);
|
||||
|
||||
const usage_calculator = ({
|
||||
usage,
|
||||
cost,
|
||||
}: {
|
||||
usage: InfronUsage;
|
||||
cost?: number;
|
||||
}) => {
|
||||
// Infron reports `cost` at the top level of the response, not
|
||||
// inside `usage`. Non-streaming calls get it via the spread
|
||||
// completion below; streaming injects it into `usage` via the
|
||||
// `index_usage_from_stream_chunk` deviation.
|
||||
const authoritativeCost =
|
||||
typeof cost === 'number' ? cost : usage.cost;
|
||||
const trackedUsage = {
|
||||
prompt:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion: usage.completion_tokens ?? 0,
|
||||
input_cache_read:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
request: 1,
|
||||
};
|
||||
if (typeof authoritativeCost === 'number') {
|
||||
// Bill the gateway-reported cost as a single line item and
|
||||
// zero the per-token costs so nothing double-bills.
|
||||
const billedTrackedUsage = { ...trackedUsage, billedUsage: 1 };
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
|
||||
);
|
||||
costOverwrites.billedUsage =
|
||||
authoritativeCost * 100_000_000 || 1;
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
billedTrackedUsage,
|
||||
actor,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
(billedTrackedUsage as Record<string, number>).usd_cents =
|
||||
authoritativeCost * 100;
|
||||
return billedTrackedUsage;
|
||||
}
|
||||
// Fallback: per-token pricing from the model catalog.
|
||||
const costOverwrites = Object.fromEntries(
|
||||
Object.keys(trackedUsage).map((k) => {
|
||||
return [
|
||||
k,
|
||||
(modelUsed.costs[k] ?? 0) *
|
||||
trackedUsage[k as keyof typeof trackedUsage],
|
||||
];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
modelUsed.id,
|
||||
costOverwrites,
|
||||
);
|
||||
return trackedUsage;
|
||||
};
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
deviations: {
|
||||
index_usage_from_stream_chunk: (chunk: {
|
||||
usage?: InfronUsage;
|
||||
cost?: number;
|
||||
}) =>
|
||||
chunk.usage
|
||||
? { ...chunk.usage, cost: chunk.cost }
|
||||
: chunk.usage,
|
||||
},
|
||||
usage_calculator,
|
||||
stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
async models() {
|
||||
let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined;
|
||||
if (!models) {
|
||||
try {
|
||||
const resp = await axios.request({
|
||||
method: 'GET',
|
||||
url: `${this.#apiBaseUrl}/models`,
|
||||
// Infron requires authentication on the catalog endpoint.
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
models = resp.data.data;
|
||||
kv.set(KV_MODELS_KEY, models, { EX: 15 * 60 }); // cache for 15 minutes
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
if (!models) return [];
|
||||
const coerced_models: IChatModel[] = [];
|
||||
for (const model of models) {
|
||||
// The catalog mixes chat with image/video/embedding/search
|
||||
// models — only chat-completion-capable models belong here.
|
||||
if (model.category_type !== 'LLM') continue;
|
||||
if (model.is_display_only) continue;
|
||||
if (!(model.supported_endpoint_types ?? []).includes('openai')) {
|
||||
continue;
|
||||
}
|
||||
// Catalog prices are USD per million tokens; costs are
|
||||
// microcents per token, so the conversion is ×100.
|
||||
const promptCost = Math.round((model.min_prompt_price ?? 0) * 100);
|
||||
coerced_models.push({
|
||||
id: `infron:${model.id}`,
|
||||
name: `${model.display_name || model.id} (Infron)`,
|
||||
aliases: [
|
||||
model.id,
|
||||
...(model.display_name ? [model.display_name] : []),
|
||||
`infron/${model.id}`,
|
||||
model.id.split('/').slice(1).join('/'),
|
||||
],
|
||||
context: model.context_length,
|
||||
max_tokens: model.max_output_tokens ?? 0,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt',
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt: promptCost,
|
||||
completion: Math.round(
|
||||
(model.min_completion_price ?? 0) * 100,
|
||||
),
|
||||
// The catalog carries no cache-read price; charge the
|
||||
// full prompt rate in the fallback path so cached
|
||||
// tokens are never billed below list. The normal path
|
||||
// bills the gateway-reported cost instead.
|
||||
input_cache_read: promptCost,
|
||||
// USD per request → microcents per request.
|
||||
request: Math.round(
|
||||
(model.min_request_price ?? 0) * 1_000_000 * 100,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
return coerced_models;
|
||||
}
|
||||
|
||||
checkModeration(
|
||||
_text: string,
|
||||
): ReturnType<IChatProvider['checkModeration']> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,25 @@ describe('MoonshotProvider.complete request shape', () => {
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('forwards model + messages and locks max_tokens=1000', async () => {
|
||||
it('forwards model + messages and passes max_tokens through unchanged', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'kimi-k2.6',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 2048,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('kimi-k2.6');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(2048);
|
||||
});
|
||||
|
||||
it('forwards max_tokens as undefined when not supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
@@ -214,11 +232,7 @@ describe('MoonshotProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('kimi-k2.6');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
// max_tokens is hardcoded by the provider — the call to Moonshot
|
||||
// should always cap at 1000 tokens of completion.
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
expect(args.max_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the `tools` key entirely when no tools are supplied', async () => {
|
||||
|
||||
@@ -21,12 +21,12 @@ 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 * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
} from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { inlineHttpImageUrls } from './imageHandling.js';
|
||||
import { MOONSHOT_MODELS } from './models.js';
|
||||
|
||||
@@ -68,6 +68,7 @@ export class MoonshotProvider implements IChatProvider {
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
const actor = Context.get('actor');
|
||||
const availableModels = this.models();
|
||||
@@ -89,7 +90,7 @@ export class MoonshotProvider implements IChatProvider {
|
||||
messages,
|
||||
model: modelUsed.id,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens: 1000,
|
||||
max_tokens,
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
|
||||
@@ -21,6 +21,27 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
// -- Flagship ----------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k3',
|
||||
id: 'kimi-k3',
|
||||
name: 'Kimi K3',
|
||||
aliases: ['moonshotai/kimi-k3', 'moonshot/kimi-k3'],
|
||||
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 300, // $3.00 per 1M
|
||||
completion_tokens: 1500, // $15.00 per 1M
|
||||
cached_tokens: 30, // $0.30 per 1M
|
||||
},
|
||||
context: 1_048_576,
|
||||
max_tokens: 1_048_576,
|
||||
tool_call: true,
|
||||
},
|
||||
|
||||
// -- Kimi K2.6 --------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k2.6',
|
||||
id: 'kimi-k2.6',
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
@@ -37,13 +38,13 @@ import type { IGenerateParams, IImageModel, IImageProvider } from './types.js';
|
||||
/**
|
||||
* Driver implementing the `puter-image-generation` interface.
|
||||
*
|
||||
* Manages multiple upstream providers and routes `generate()` calls
|
||||
* based on the requested model. Mirrors ChatCompletionDriver's pattern:
|
||||
* providers are instantiated from config on boot, a model map is built
|
||||
* from each provider's declared models, and calls are dispatched.
|
||||
* Manages multiple upstream providers and routes `generate()` calls based on
|
||||
* the requested model. Mirrors ChatCompletionDriver's pattern: providers are
|
||||
* instantiated from config on boot, a model map is built from each provider's
|
||||
* declared models, and calls are dispatched.
|
||||
*
|
||||
* Output is a URL string (web URL or data URI) — no streaming, no
|
||||
* TypedValue wrapper.
|
||||
* Output is a URL string (web URL or data URI) — no streaming, no TypedValue
|
||||
* wrapper.
|
||||
*/
|
||||
export class ImageGenerationDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-image-generation';
|
||||
|
||||
@@ -53,6 +53,7 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
|
||||
'google/gemini-2.5-flash-image-preview',
|
||||
'google/gemini-2.5-flash-image',
|
||||
'google:google/gemini-2.5-flash-image-preview',
|
||||
'nano-banana',
|
||||
],
|
||||
|
||||
name: 'Gemini 2.5 Flash Image',
|
||||
@@ -94,6 +95,7 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
|
||||
'google/gemini-3-pro-image-preview',
|
||||
'google/gemini-3-pro-image',
|
||||
'google:google/gemini-3-pro-image-preview',
|
||||
'nano-banana-pro',
|
||||
],
|
||||
allowedQualityLevels: ['1K', '2K', '4K'],
|
||||
allowedRatios: [
|
||||
|
||||
@@ -374,6 +374,23 @@ it('meters one usage line per detected page at the per-page rate from costs.ts',
|
||||
// ── Mistral OCR ─────────────────────────────────────────────────────
|
||||
|
||||
describe('OCRDriver.recognize (mistral)', () => {
|
||||
it('throws 402 when the actor does not have enough credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
const { actor } = await makeUser();
|
||||
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.recognize({
|
||||
source: dataUrl(Buffer.from('img'), 'image/png'),
|
||||
provider: 'mistral',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
|
||||
// The paid Mistral call must not happen when credits are short.
|
||||
expect(mistralOcrProcessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('packages an image as an image_url chunk with a base64 data URL', async () => {
|
||||
const { actor } = await makeUser();
|
||||
mistralOcrProcessMock.mockResolvedValueOnce({
|
||||
@@ -570,6 +587,39 @@ describe('OCRDriver.recognize (mistral)', () => {
|
||||
|
||||
// ── Default provider selection ──────────────────────────────────────
|
||||
|
||||
describe('OCRDriver provider aliases', () => {
|
||||
it.each([
|
||||
['aws', 'textract'],
|
||||
['textract', 'textract'],
|
||||
['aws-textract', 'textract'],
|
||||
['mistral', 'mistral'],
|
||||
['mistral-ocr', 'mistral'],
|
||||
])('routes provider %s to the %s backend', async (provider, expected) => {
|
||||
const { actor } = await makeUser();
|
||||
textractSendMock.mockResolvedValueOnce({
|
||||
Blocks: [{ BlockType: 'PAGE' }],
|
||||
});
|
||||
mistralOcrProcessMock.mockResolvedValueOnce({
|
||||
pages: [{ index: 0, markdown: 'hi' }],
|
||||
});
|
||||
|
||||
await withActor(actor, () =>
|
||||
driver.recognize({
|
||||
source: dataUrl(Buffer.from('img'), 'image/png'),
|
||||
provider,
|
||||
}),
|
||||
);
|
||||
|
||||
if (expected === 'textract') {
|
||||
expect(textractSendMock).toHaveBeenCalledTimes(1);
|
||||
expect(mistralOcrProcessMock).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mistralOcrProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(textractSendMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OCRDriver default provider selection', () => {
|
||||
it('defaults to aws-textract when both providers are configured', async () => {
|
||||
const { actor } = await makeUser();
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -33,9 +34,9 @@ import { loadFileInput, type LoadedFile } from '../util/fileInput.js';
|
||||
import { OCR_COSTS } from './costs.js';
|
||||
|
||||
/**
|
||||
* Driver implementing `puter-ocr` — document OCR. Two providers:
|
||||
* • `aws-textract` — AWS Textract (region-aware clients; direct S3 source when available)
|
||||
* • `mistral` — Mistral OCR (URL/data-URL based)
|
||||
* Driver implementing `puter-ocr` — document OCR. Two providers: •
|
||||
* `aws-textract` — AWS Textract (region-aware clients; direct S3 source when
|
||||
* available) • `mistral` — Mistral OCR (URL/data-URL based)
|
||||
*/
|
||||
interface RecognizeArgs {
|
||||
source?: unknown;
|
||||
@@ -77,6 +78,23 @@ interface MistralOcrClient {
|
||||
};
|
||||
}
|
||||
|
||||
const OCR_PROVIDERS = ['aws-textract', 'mistral'] as const;
|
||||
|
||||
// Aliases callers may use in place of a canonical provider id. Resolved here
|
||||
// rather than in the SDK so a new alias reaches every caller at once.
|
||||
const PROVIDER_BY_ALIAS: Record<string, string> = {
|
||||
aws: 'aws-textract',
|
||||
'aws-textract': 'aws-textract',
|
||||
textract: 'aws-textract',
|
||||
mistral: 'mistral',
|
||||
'mistral-ocr': 'mistral',
|
||||
};
|
||||
|
||||
const normalizeOcrProvider = (value: unknown): string | undefined =>
|
||||
typeof value === 'string'
|
||||
? PROVIDER_BY_ALIAS[value.trim().toLowerCase()]
|
||||
: undefined;
|
||||
|
||||
export class OCRDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-ocr';
|
||||
readonly driverName = 'ai-ocr';
|
||||
@@ -94,11 +112,10 @@ export class OCRDriver extends PuterDriver {
|
||||
}));
|
||||
}
|
||||
|
||||
// puter-js's `img2txt` routes by passing the provider id in the `driver`
|
||||
// slot. Alias them so the unified driver resolves; `recognize` falls
|
||||
// back to the alias via Context.driverName when `args.provider` isn't
|
||||
// set.
|
||||
readonly driverAliases = ['aws-textract', 'mistral'];
|
||||
// Older SDK bundles name the provider in the driver slot instead of
|
||||
// passing `{ provider }`; `#resolveProvider` reads the requested alias
|
||||
// back off the Context.
|
||||
readonly driverAliases = [...OCR_PROVIDERS];
|
||||
readonly isDefault = true;
|
||||
|
||||
// Textract state — one client per region.
|
||||
@@ -155,10 +172,7 @@ export class OCRDriver extends PuterDriver {
|
||||
async recognize(args: RecognizeArgs) {
|
||||
if (args.test_mode) return sampleResponse();
|
||||
|
||||
const provider =
|
||||
args.provider ??
|
||||
(Context.get('driverName') as string | undefined) ??
|
||||
this.#defaultProvider();
|
||||
const provider = this.#resolveProvider(args);
|
||||
if (!provider)
|
||||
throw new HttpError(500, 'No OCR provider configured', {
|
||||
legacyCode: 'internal_error',
|
||||
@@ -203,6 +217,29 @@ export class OCRDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which provider handles a call: an explicit `provider` wins, then
|
||||
* the legacy driver alias the caller dispatched through, then whichever
|
||||
* provider is configured.
|
||||
*/
|
||||
#resolveProvider(args: RecognizeArgs): string | null {
|
||||
if (args.provider) {
|
||||
const named = normalizeOcrProvider(args.provider);
|
||||
if (!named) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Unknown OCR provider: ${args.provider}. Available: ${OCR_PROVIDERS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return named;
|
||||
}
|
||||
return (
|
||||
normalizeOcrProvider(Context.get('driverName')) ??
|
||||
this.#defaultProvider()
|
||||
);
|
||||
}
|
||||
|
||||
#defaultProvider(): 'aws-textract' | 'mistral' | null {
|
||||
if (this.#awsConfig) return 'aws-textract';
|
||||
if (this.#mistral) return 'mistral';
|
||||
@@ -324,6 +361,18 @@ export class OCRDriver extends PuterDriver {
|
||||
args: RecognizeArgs,
|
||||
actor: Actor,
|
||||
) {
|
||||
// Gate on credits before the paid upstream call, mirroring the
|
||||
// Textract branch. Page count isn't known until Mistral responds, so
|
||||
// pre-flight one page's cost and meter the real total afterward.
|
||||
const hasCredits = await this.services.metering.hasEnoughCredits(
|
||||
actor,
|
||||
OCR_COSTS['mistral-ocr:ocr:page'],
|
||||
);
|
||||
if (!hasCredits)
|
||||
throw new HttpError(402, 'Insufficient credits', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
|
||||
const model = args.model ?? 'mistral-ocr-latest';
|
||||
const chunk = this.#mistralBuildChunk(loaded);
|
||||
const payload: Record<string, unknown> = { model, document: chunk };
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -27,8 +28,8 @@ import { loadFileInput } from '../util/fileInput.js';
|
||||
import { VOICE_CHANGER_COSTS } from './costs.js';
|
||||
|
||||
/**
|
||||
* Driver implementing `puter-speech2speech` — voice changer. Currently a
|
||||
* single provider (ElevenLabs).
|
||||
* Driver implementing `puter-speech2speech` — voice changer. Currently a single
|
||||
* provider (ElevenLabs).
|
||||
*/
|
||||
|
||||
const DEFAULT_MODEL = 'eleven_multilingual_sts_v2';
|
||||
@@ -37,8 +38,11 @@ const DEFAULT_OUTPUT_FORMAT = 'mp3_44100_128';
|
||||
const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
|
||||
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
const PROVIDERS = ['elevenlabs'] as const;
|
||||
|
||||
interface ConvertArgs {
|
||||
audio: unknown;
|
||||
provider?: string;
|
||||
voice?: string;
|
||||
voice_id?: string;
|
||||
voiceId?: string;
|
||||
@@ -57,7 +61,9 @@ interface ConvertArgs {
|
||||
|
||||
export class VoiceChangerDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-speech2speech';
|
||||
readonly driverName = 'elevenlabs-voice-changer';
|
||||
readonly driverName = 'ai-speech2speech';
|
||||
// Older SDK bundles name the provider in the driver slot.
|
||||
readonly driverAliases = ['elevenlabs-voice-changer'];
|
||||
readonly isDefault = true;
|
||||
|
||||
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
|
||||
@@ -103,6 +109,23 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
async convert(
|
||||
args: ConvertArgs,
|
||||
): Promise<DriverStreamResult | { url: string; content_type: string }> {
|
||||
// Only one provider exists today, but naming a different one should
|
||||
// fail loudly rather than quietly convert with this one.
|
||||
if (
|
||||
args.provider &&
|
||||
!PROVIDERS.includes(
|
||||
args.provider
|
||||
.trim()
|
||||
.toLowerCase() as (typeof PROVIDERS)[number],
|
||||
)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Speech-to-speech provider not found: ${args.provider}. Available: ${PROVIDERS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
if (args.test_mode) {
|
||||
return { url: SAMPLE_AUDIO_URL, content_type: 'audio/mpeg' };
|
||||
}
|
||||
|
||||
@@ -194,7 +194,11 @@ describe('SpeechToTextDriver.getReportedCosts', () => {
|
||||
it('mirrors every entry in costs.ts as a per-second line item', () => {
|
||||
const reported = driver.getReportedCosts();
|
||||
|
||||
expect(reported).toHaveLength(Object.keys(SPEECH_TO_TEXT_COSTS).length);
|
||||
// One extra line item comes from the xAI provider, which the
|
||||
// driver aggregates alongside OpenAI's catalogue.
|
||||
expect(reported).toHaveLength(
|
||||
Object.keys(SPEECH_TO_TEXT_COSTS).length + 1,
|
||||
);
|
||||
for (const [usageType, ucentsPerUnit] of Object.entries(
|
||||
SPEECH_TO_TEXT_COSTS,
|
||||
)) {
|
||||
@@ -784,3 +788,75 @@ describe('SpeechToTextDriver error paths', () => {
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Provider routing ────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechToTextDriver provider routing', () => {
|
||||
it('defaults to openai when no provider is named', async () => {
|
||||
const { actor } = await makeUser();
|
||||
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'hi' });
|
||||
|
||||
await withActor(actor, () =>
|
||||
driver.transcribe({ file: dataUrl(Buffer.from('a'), 'audio/mp3') }),
|
||||
);
|
||||
|
||||
expect(transcriptionsCreateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['xai', 'grok', 'x-ai'])(
|
||||
'routes provider %s away from openai',
|
||||
async (provider) => {
|
||||
const { actor } = await makeUser();
|
||||
|
||||
// This server carries no xAI key, so the provider's own
|
||||
// "not configured" rejection is the proof the call was routed
|
||||
// there rather than to OpenAI.
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.transcribe({
|
||||
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
|
||||
provider,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
message: 'xAI API key not configured',
|
||||
});
|
||||
expect(transcriptionsCreateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['openai', 'whisper'])(
|
||||
'routes provider %s to the OpenAI backend',
|
||||
async (provider) => {
|
||||
const { actor } = await makeUser();
|
||||
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'hi' });
|
||||
|
||||
await withActor(actor, () =>
|
||||
driver.transcribe({
|
||||
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
|
||||
provider,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(transcriptionsCreateMock).toHaveBeenCalledTimes(1);
|
||||
// `provider` is the driver's business, never forwarded upstream.
|
||||
expect(
|
||||
transcriptionsCreateMock.mock.calls[0]![0],
|
||||
).not.toHaveProperty('provider');
|
||||
},
|
||||
);
|
||||
|
||||
it('throws 400 when the named provider is unknown', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.transcribe({
|
||||
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
|
||||
provider: 'totally-not-real',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(transcriptionsCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,345 +3,228 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
import { SPEECH_TO_TEXT_COSTS } from './costs.js';
|
||||
import {
|
||||
DEFAULT_SPEECH_TO_TEXT_PROVIDER,
|
||||
normalizeSpeechToTextProvider,
|
||||
SPEECH_TO_TEXT_DRIVER_ALIASES,
|
||||
SPEECH_TO_TEXT_PROVIDERS,
|
||||
} from './providerAliases.js';
|
||||
import { OpenAISpeechToTextProvider } from './providers/openai/OpenAISpeechToTextProvider.js';
|
||||
import { XAISpeechToTextProvider } from './providers/xai/XAISpeechToTextProvider.js';
|
||||
import type {
|
||||
ISpeechToTextDeps,
|
||||
ISpeechToTextModel,
|
||||
ISpeechToTextProvider,
|
||||
ITranscribeArgs,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Driver implementing `puter-speech2txt`. Wraps OpenAI's audio API
|
||||
* (Whisper + GPT-4o transcribe models) for transcription and translation.
|
||||
* Driver implementing the `puter-speech2txt` interface.
|
||||
*
|
||||
* `file` may be a path, uid/uuid ref, or data URL.
|
||||
* Manages the upstream transcription providers and routes each call to the one
|
||||
* the caller named. Each provider is an `ISpeechToTextProvider` instantiated
|
||||
* from config on boot.
|
||||
*/
|
||||
|
||||
const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-mini-transcribe';
|
||||
const DEFAULT_TRANSLATE_MODEL = 'whisper-1';
|
||||
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
||||
/** Opt-in value that widens `list_models` out to every provider. */
|
||||
const ALL_PROVIDERS = 'all';
|
||||
|
||||
const SAMPLE_TRANSCRIPT = {
|
||||
text: 'Hello! This is a sample transcription returned while test mode is enabled.',
|
||||
language: 'en',
|
||||
duration_seconds: 2,
|
||||
words: [
|
||||
{ start: 0.0, end: 0.5, text: 'Hello' },
|
||||
{ start: 1.1, end: 2.0, text: 'This is a sample transcription.' },
|
||||
],
|
||||
};
|
||||
|
||||
interface ModelCapabilities {
|
||||
canPrompt: boolean;
|
||||
canLogprobs: boolean;
|
||||
responseFormats: string[];
|
||||
timestampGranularities?: boolean;
|
||||
diarization?: boolean;
|
||||
requiresChunkingOverThirtySeconds?: boolean;
|
||||
}
|
||||
|
||||
const MODEL_CAPS: Record<string, ModelCapabilities> = {
|
||||
'gpt-4o-mini-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe-diarize': {
|
||||
canPrompt: false,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'diarized_json'],
|
||||
diarization: true,
|
||||
requiresChunkingOverThirtySeconds: true,
|
||||
},
|
||||
'whisper-1': {
|
||||
canPrompt: true,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'srt', 'verbose_json', 'vtt'],
|
||||
timestampGranularities: true,
|
||||
},
|
||||
};
|
||||
|
||||
interface TranscribeArgs {
|
||||
file: unknown;
|
||||
model?: string;
|
||||
response_format?: string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
temperature?: number;
|
||||
logprobs?: boolean;
|
||||
timestamp_granularities?: string[];
|
||||
chunking_strategy?: string;
|
||||
known_speaker_names?: string[];
|
||||
known_speaker_references?: unknown[];
|
||||
extra_body?: Record<string, unknown>;
|
||||
stream?: boolean;
|
||||
test_mode?: boolean;
|
||||
}
|
||||
const isAllProviders = (value: unknown): boolean =>
|
||||
typeof value === 'string' && value.trim().toLowerCase() === ALL_PROVIDERS;
|
||||
|
||||
export class SpeechToTextDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-speech2txt';
|
||||
readonly driverName = 'openai-speech2txt';
|
||||
readonly driverName = 'ai-speech2txt';
|
||||
// Older SDK bundles name the provider in the driver slot instead of
|
||||
// passing `{ provider }`; `#resolveProvider` reads the requested alias
|
||||
// back off the Context.
|
||||
readonly driverAliases = [...SPEECH_TO_TEXT_DRIVER_ALIASES];
|
||||
readonly isDefault = true;
|
||||
|
||||
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
|
||||
// The XAI sibling driver implements the same interface so both share
|
||||
// the per-user bucket (keyed by interface+method+user).
|
||||
// One bucket covers every provider, keyed by interface+method+user.
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
#providers: Record<string, ISpeechToTextProvider> = {};
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
}
|
||||
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
return Object.entries(SPEECH_TO_TEXT_COSTS).map(
|
||||
([usageType, ucentsPerUnit]) => ({
|
||||
usageType,
|
||||
ucentsPerUnit,
|
||||
unit: 'second',
|
||||
source: 'driver:aiSpeech2Txt',
|
||||
}),
|
||||
return Object.values(this.#providers).flatMap((p) =>
|
||||
p.getReportedCosts(),
|
||||
);
|
||||
}
|
||||
|
||||
#openai: OpenAI | null = null;
|
||||
// -- Interface methods -------------------------------------------
|
||||
|
||||
override onServerStart() {
|
||||
/**
|
||||
* List available models. Defaults to the default provider; pass `provider:
|
||||
* 'all'` to aggregate across every configured provider.
|
||||
*/
|
||||
async list_models(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<ISpeechToTextModel[]> {
|
||||
const requested = args?.provider;
|
||||
if (isAllProviders(requested)) {
|
||||
const all: ISpeechToTextModel[] = [];
|
||||
for (const p of Object.values(this.#providers)) {
|
||||
all.push(
|
||||
...(await p.listModels()).map((m) => ({
|
||||
...m,
|
||||
provider: p.providerName,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
const p =
|
||||
this.#providers[this.#resolveProvider({ provider: requested })];
|
||||
if (!p) return [];
|
||||
return p.listModels();
|
||||
}
|
||||
|
||||
/** List provider names that are currently configured. */
|
||||
async list(): Promise<string[]> {
|
||||
return Object.keys(this.#providers);
|
||||
}
|
||||
|
||||
async transcribe(args: ITranscribeArgs) {
|
||||
return this.#provider(args).transcribe(this.#providerArgs(args));
|
||||
}
|
||||
|
||||
async translate(args: ITranscribeArgs) {
|
||||
return this.#provider(args).translate(this.#providerArgs(args));
|
||||
}
|
||||
|
||||
// -- Provider routing --------------------------------------------
|
||||
|
||||
#provider(args: ITranscribeArgs): ISpeechToTextProvider {
|
||||
const providerName = this.#resolveProvider(args);
|
||||
const provider = this.#providers[providerName];
|
||||
if (!provider) {
|
||||
throw new HttpError(
|
||||
500,
|
||||
`Speech-to-text provider not configured: ${providerName}`,
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which provider handles a call: an explicit `provider` wins, then
|
||||
* the legacy driver alias the caller dispatched through, then the default.
|
||||
*/
|
||||
#resolveProvider(args: { provider?: unknown }): string {
|
||||
if (
|
||||
args.provider !== undefined &&
|
||||
args.provider !== null &&
|
||||
args.provider !== ''
|
||||
) {
|
||||
const named = normalizeSpeechToTextProvider(args.provider);
|
||||
if (!named) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Speech-to-text provider not found: ${String(args.provider)}. Available: ${SPEECH_TO_TEXT_PROVIDERS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return named;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeSpeechToTextProvider(Context.get('driverName')) ??
|
||||
this.#defaultProvider()
|
||||
);
|
||||
}
|
||||
|
||||
/** Providers read their own options; `provider` is the driver's business. */
|
||||
#providerArgs(args: ITranscribeArgs): ITranscribeArgs {
|
||||
const { provider: _provider, ...rest } = args;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented default, falling back to whatever is configured so a
|
||||
* deployment without OpenAI credentials still serves transcription.
|
||||
*/
|
||||
#defaultProvider(): string {
|
||||
for (const name of [
|
||||
DEFAULT_SPEECH_TO_TEXT_PROVIDER,
|
||||
...SPEECH_TO_TEXT_PROVIDERS,
|
||||
]) {
|
||||
if (this.#providers[name]) return name;
|
||||
}
|
||||
return (
|
||||
Object.keys(this.#providers)[0] ?? DEFAULT_SPEECH_TO_TEXT_PROVIDER
|
||||
);
|
||||
}
|
||||
|
||||
// -- Provider registration ---------------------------------------
|
||||
|
||||
// Providers register whether or not credentials are present, so their
|
||||
// model catalogues stay listable on a deployment that only configures
|
||||
// some of them. A provider without a key rejects at call time.
|
||||
#registerProviders() {
|
||||
const providers = (this.config.providers ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
>;
|
||||
const readKey = (
|
||||
...cfgs: Array<Record<string, unknown> | undefined>
|
||||
): string | undefined => {
|
||||
for (const cfg of cfgs) {
|
||||
if (!cfg) continue;
|
||||
const k =
|
||||
(cfg.apiKey as string | undefined) ??
|
||||
(cfg.secret_key as string | undefined);
|
||||
if (k) return k;
|
||||
}
|
||||
return undefined;
|
||||
const deps: ISpeechToTextDeps = {
|
||||
stores: this.stores,
|
||||
fs: this.services.fs,
|
||||
metering: this.services.metering,
|
||||
};
|
||||
const apiKey = readKey(
|
||||
providers['openai-speech-to-text'],
|
||||
providers['openai-completion'],
|
||||
providers['openai'],
|
||||
);
|
||||
if (!apiKey) return; // Leave uninitialized; convert() will reject.
|
||||
this.#openai = new OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
async list_models() {
|
||||
return Object.entries(MODEL_CAPS).map(([id, caps]) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: caps.diarization
|
||||
? 'transcription'
|
||||
: id === 'whisper-1'
|
||||
? 'translation'
|
||||
: 'transcription',
|
||||
response_formats: caps.responseFormats,
|
||||
supports_prompt: caps.canPrompt,
|
||||
supports_logprobs: caps.canLogprobs,
|
||||
...(caps.diarization ? { supports_diarization: true } : {}),
|
||||
...(caps.timestampGranularities
|
||||
? { supports_timestamp_granularities: true }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
this.#providers['openai'] = new OpenAISpeechToTextProvider(deps, {
|
||||
apiKey: readKey(
|
||||
providers['openai-speech-to-text'],
|
||||
providers['openai-completion'],
|
||||
providers['openai'],
|
||||
),
|
||||
});
|
||||
|
||||
async transcribe(args: TranscribeArgs) {
|
||||
return this.#handleTranscription(args, false);
|
||||
}
|
||||
|
||||
async translate(args: TranscribeArgs) {
|
||||
return this.#handleTranscription(args, true);
|
||||
}
|
||||
|
||||
async #handleTranscription(args: TranscribeArgs, translate: boolean) {
|
||||
if (args.test_mode) {
|
||||
return {
|
||||
...SAMPLE_TRANSCRIPT,
|
||||
model:
|
||||
args.model ||
|
||||
(translate
|
||||
? DEFAULT_TRANSLATE_MODEL
|
||||
: DEFAULT_TRANSCRIBE_MODEL),
|
||||
};
|
||||
}
|
||||
if (args.stream) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Streaming transcription is not yet supported',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (!this.#openai)
|
||||
throw new HttpError(500, 'OpenAI API key not configured', {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
if (!args.file)
|
||||
throw new HttpError(400, '`file` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
|
||||
const loaded = await loadFileInput(
|
||||
this.stores,
|
||||
this.services.fs,
|
||||
actor,
|
||||
args.file,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
|
||||
);
|
||||
|
||||
const selectedModel =
|
||||
args.model ||
|
||||
(translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL);
|
||||
const caps = MODEL_CAPS[selectedModel];
|
||||
if (!caps) {
|
||||
throw new HttpError(400, `Unsupported model: ${selectedModel}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
args.response_format &&
|
||||
!caps.responseFormats.includes(args.response_format)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`response_format must be one of: ${caps.responseFormats.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (args.prompt && !caps.canPrompt) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`prompt is not supported for model ${selectedModel}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (args.logprobs && !caps.canLogprobs) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`logprobs is not supported for model ${selectedModel}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
// Estimate seconds from raw bytes — 16 kbps is a conservative speech-audio
|
||||
// lower bound. Full metadata parsing (music-metadata) is deferred — clients
|
||||
// aren't observably sensitive to billing-time delta vs real duration.
|
||||
const estimatedSeconds = Math.max(
|
||||
1,
|
||||
Math.ceil(loaded.buffer.byteLength / 16000),
|
||||
);
|
||||
const usageType = `openai:${selectedModel}:second`;
|
||||
const ucentsPerSecond = SPEECH_TO_TEXT_COSTS[usageType] ?? 0;
|
||||
const estimatedCost = ucentsPerSecond * estimatedSeconds;
|
||||
const allowed = await this.services.metering.hasEnoughCredits(
|
||||
actor,
|
||||
estimatedCost,
|
||||
);
|
||||
if (!allowed)
|
||||
throw new HttpError(402, 'Insufficient credits', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
|
||||
const openaiFile = await toFile(
|
||||
loaded.buffer,
|
||||
loaded.filename,
|
||||
loaded.mimeType ? { type: loaded.mimeType } : undefined,
|
||||
);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
file: openaiFile,
|
||||
model: selectedModel,
|
||||
};
|
||||
if (args.response_format)
|
||||
payload.response_format = args.response_format;
|
||||
if (args.language) payload.language = args.language;
|
||||
if (typeof args.temperature === 'number')
|
||||
payload.temperature = args.temperature;
|
||||
if (args.prompt && caps.canPrompt) payload.prompt = args.prompt;
|
||||
if (args.logprobs && caps.canLogprobs) payload.logprobs = args.logprobs;
|
||||
if (args.timestamp_granularities && caps.timestampGranularities) {
|
||||
payload.timestamp_granularities = args.timestamp_granularities;
|
||||
}
|
||||
if (caps.diarization) {
|
||||
if (!args.response_format)
|
||||
payload.response_format = 'diarized_json';
|
||||
const needsChunking =
|
||||
caps.requiresChunkingOverThirtySeconds && estimatedSeconds > 30;
|
||||
const strategy =
|
||||
args.chunking_strategy ?? (needsChunking ? 'auto' : undefined);
|
||||
if (strategy) payload.chunking_strategy = strategy;
|
||||
|
||||
if (args.known_speaker_names || args.known_speaker_references) {
|
||||
payload.extra_body = {
|
||||
...(args.extra_body ?? {}),
|
||||
...(args.known_speaker_names
|
||||
? { known_speaker_names: args.known_speaker_names }
|
||||
: {}),
|
||||
...(args.known_speaker_references
|
||||
? {
|
||||
known_speaker_references:
|
||||
args.known_speaker_references,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} else if (args.extra_body) {
|
||||
payload.extra_body = args.extra_body;
|
||||
}
|
||||
|
||||
const result = translate
|
||||
? await this.#openai.audio.translations.create(
|
||||
payload as Parameters<
|
||||
OpenAI['audio']['translations']['create']
|
||||
>[0],
|
||||
)
|
||||
: await this.#openai.audio.transcriptions.create(
|
||||
payload as Parameters<
|
||||
OpenAI['audio']['transcriptions']['create']
|
||||
>[0],
|
||||
);
|
||||
|
||||
this.services.metering.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
estimatedSeconds,
|
||||
ucentsPerSecond * estimatedSeconds,
|
||||
);
|
||||
|
||||
// Text response_format: return raw string; otherwise forward the OpenAI object.
|
||||
if (args.response_format === 'text') {
|
||||
return typeof result === 'string'
|
||||
? result
|
||||
: ((result as { text?: string }).text ?? '');
|
||||
}
|
||||
return result;
|
||||
this.#providers['xai'] = new XAISpeechToTextProvider(deps, {
|
||||
apiKey: readKey(providers['xai']),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** First API key found across the config shapes providers are declared with. */
|
||||
function readKey(
|
||||
...cfgs: Array<Record<string, unknown> | undefined>
|
||||
): string | undefined {
|
||||
for (const cfg of cfgs) {
|
||||
if (!cfg) continue;
|
||||
const k =
|
||||
(cfg.apiKey as string | undefined) ??
|
||||
(cfg.secret_key as string | undefined) ??
|
||||
(cfg.api_key as string | undefined) ??
|
||||
(cfg.key as string | undefined);
|
||||
if (k) return k;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical `puter-speech2txt` provider ids and the aliases callers may use in
|
||||
* their place. Resolution lives here rather than in the SDK so a new alias
|
||||
* reaches every caller at once, including clients on an older bundle.
|
||||
*/
|
||||
|
||||
export const SPEECH_TO_TEXT_PROVIDERS = ['openai', 'xai'] as const;
|
||||
|
||||
export type SpeechToTextProviderName =
|
||||
(typeof SPEECH_TO_TEXT_PROVIDERS)[number];
|
||||
|
||||
/** The documented default when a caller names no provider. */
|
||||
export const DEFAULT_SPEECH_TO_TEXT_PROVIDER: SpeechToTextProviderName =
|
||||
'openai';
|
||||
|
||||
/**
|
||||
* Driver names the unified driver answers to. Older SDK bundles put the
|
||||
* provider in the driver slot instead of passing `{ provider }`.
|
||||
*/
|
||||
export const SPEECH_TO_TEXT_DRIVER_ALIASES = [
|
||||
'openai-speech2txt',
|
||||
'xai-speech2txt',
|
||||
] as const;
|
||||
|
||||
const PROVIDER_BY_ALIAS: Record<string, SpeechToTextProviderName> = {
|
||||
openai: 'openai',
|
||||
'openai-speech2txt': 'openai',
|
||||
whisper: 'openai',
|
||||
grok: 'xai',
|
||||
'x-ai': 'xai',
|
||||
xai: 'xai',
|
||||
'xai-speech2txt': 'xai',
|
||||
};
|
||||
|
||||
/** Resolve a caller-supplied provider name, or `undefined` if unrecognized. */
|
||||
export function normalizeSpeechToTextProvider(
|
||||
value: unknown,
|
||||
): SpeechToTextProviderName | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return PROVIDER_BY_ALIAS[value.trim().toLowerCase()];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Abstract base for speech-to-text providers. Each provider wraps a single
|
||||
* upstream API and exposes the unified `ISpeechToTextProvider` contract.
|
||||
*/
|
||||
|
||||
import { Context } from '../../../core/context.js';
|
||||
import { HttpError } from '../../../core/http/HttpError.js';
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import type {
|
||||
ISpeechToTextDeps,
|
||||
ISpeechToTextModel,
|
||||
ISpeechToTextProvider,
|
||||
ITranscribeArgs,
|
||||
} from '../types.js';
|
||||
|
||||
export abstract class SpeechToTextProvider implements ISpeechToTextProvider {
|
||||
abstract readonly providerName: string;
|
||||
|
||||
constructor(protected deps: ISpeechToTextDeps) {}
|
||||
|
||||
abstract listModels(): Promise<ISpeechToTextModel[]>;
|
||||
|
||||
abstract transcribe(args: ITranscribeArgs): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Translate to English. Providers whose upstream has no separate
|
||||
* translation endpoint override this to delegate to `transcribe`.
|
||||
*/
|
||||
abstract translate(args: ITranscribeArgs): Promise<unknown>;
|
||||
|
||||
getReportedCosts(): Record<string, unknown>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The authenticated caller, or a 401 if the request carries none. */
|
||||
protected requireActor(): Actor {
|
||||
const actor = Context.get('actor') as Actor | undefined;
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
/** Reject the call unless the caller supplied audio. */
|
||||
protected requireFile(args: ITranscribeArgs): void {
|
||||
if (!args.file)
|
||||
throw new HttpError(400, '`file` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import OpenAI, { toFile } from 'openai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { loadFileInput } from '../../../util/fileInput.js';
|
||||
import { SPEECH_TO_TEXT_COSTS } from '../../costs.js';
|
||||
import type {
|
||||
ISpeechToTextDeps,
|
||||
ISpeechToTextModel,
|
||||
ITranscribeArgs,
|
||||
} from '../../types.js';
|
||||
import { SpeechToTextProvider } from '../SpeechToTextProvider.js';
|
||||
|
||||
/**
|
||||
* Wraps OpenAI's audio API (Whisper + GPT-4o transcribe models) for
|
||||
* transcription and translation.
|
||||
*
|
||||
* `file` may be a path, uid/uuid ref, or data URL.
|
||||
*/
|
||||
|
||||
const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-mini-transcribe';
|
||||
const DEFAULT_TRANSLATE_MODEL = 'whisper-1';
|
||||
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
const SAMPLE_TRANSCRIPT = {
|
||||
text: 'Hello! This is a sample transcription returned while test mode is enabled.',
|
||||
language: 'en',
|
||||
duration_seconds: 2,
|
||||
words: [
|
||||
{ start: 0.0, end: 0.5, text: 'Hello' },
|
||||
{ start: 1.1, end: 2.0, text: 'This is a sample transcription.' },
|
||||
],
|
||||
};
|
||||
|
||||
interface ModelCapabilities {
|
||||
canPrompt: boolean;
|
||||
canLogprobs: boolean;
|
||||
responseFormats: string[];
|
||||
timestampGranularities?: boolean;
|
||||
diarization?: boolean;
|
||||
requiresChunkingOverThirtySeconds?: boolean;
|
||||
}
|
||||
|
||||
const MODEL_CAPS: Record<string, ModelCapabilities> = {
|
||||
'gpt-4o-mini-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe-diarize': {
|
||||
canPrompt: false,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'diarized_json'],
|
||||
diarization: true,
|
||||
requiresChunkingOverThirtySeconds: true,
|
||||
},
|
||||
'whisper-1': {
|
||||
canPrompt: true,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'srt', 'verbose_json', 'vtt'],
|
||||
timestampGranularities: true,
|
||||
},
|
||||
};
|
||||
|
||||
export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
|
||||
readonly providerName = 'openai';
|
||||
|
||||
// Null when the deployment has no OpenAI credentials. The provider still
|
||||
// registers so its model catalogue stays listable; transcription rejects.
|
||||
#openai: OpenAI | null;
|
||||
|
||||
constructor(deps: ISpeechToTextDeps, config: { apiKey?: string }) {
|
||||
super(deps);
|
||||
this.#openai = config.apiKey
|
||||
? new OpenAI({ apiKey: config.apiKey })
|
||||
: null;
|
||||
}
|
||||
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
return Object.entries(SPEECH_TO_TEXT_COSTS).map(
|
||||
([usageType, ucentsPerUnit]) => ({
|
||||
usageType,
|
||||
ucentsPerUnit,
|
||||
unit: 'second',
|
||||
source: 'driver:aiSpeech2Txt',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listModels(): Promise<ISpeechToTextModel[]> {
|
||||
return Object.entries(MODEL_CAPS).map(([id, caps]) => ({
|
||||
id,
|
||||
name: id,
|
||||
type: caps.diarization
|
||||
? 'transcription'
|
||||
: id === 'whisper-1'
|
||||
? 'translation'
|
||||
: 'transcription',
|
||||
response_formats: caps.responseFormats,
|
||||
supports_prompt: caps.canPrompt,
|
||||
supports_logprobs: caps.canLogprobs,
|
||||
...(caps.diarization ? { supports_diarization: true } : {}),
|
||||
...(caps.timestampGranularities
|
||||
? { supports_timestamp_granularities: true }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async transcribe(args: ITranscribeArgs) {
|
||||
return this.#handleTranscription(args, false);
|
||||
}
|
||||
|
||||
async translate(args: ITranscribeArgs) {
|
||||
return this.#handleTranscription(args, true);
|
||||
}
|
||||
|
||||
async #handleTranscription(args: ITranscribeArgs, translate: boolean) {
|
||||
if (args.test_mode) {
|
||||
return {
|
||||
...SAMPLE_TRANSCRIPT,
|
||||
model:
|
||||
args.model ||
|
||||
(translate
|
||||
? DEFAULT_TRANSLATE_MODEL
|
||||
: DEFAULT_TRANSCRIBE_MODEL),
|
||||
};
|
||||
}
|
||||
if (args.stream) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Streaming transcription is not yet supported',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (!this.#openai)
|
||||
throw new HttpError(500, 'OpenAI API key not configured', {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
this.requireFile(args);
|
||||
|
||||
const actor = this.requireActor();
|
||||
|
||||
const loaded = await loadFileInput(
|
||||
this.deps.stores,
|
||||
this.deps.fs,
|
||||
actor,
|
||||
args.file,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
|
||||
);
|
||||
|
||||
const selectedModel =
|
||||
args.model ||
|
||||
(translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL);
|
||||
const caps = MODEL_CAPS[selectedModel];
|
||||
if (!caps) {
|
||||
throw new HttpError(400, `Unsupported model: ${selectedModel}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
args.response_format &&
|
||||
!caps.responseFormats.includes(args.response_format)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`response_format must be one of: ${caps.responseFormats.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (args.prompt && !caps.canPrompt) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`prompt is not supported for model ${selectedModel}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (args.logprobs && !caps.canLogprobs) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`logprobs is not supported for model ${selectedModel}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
// Estimate seconds from raw bytes — 16 kbps is a conservative speech-audio
|
||||
// lower bound. Full metadata parsing (music-metadata) is deferred — clients
|
||||
// aren't observably sensitive to billing-time delta vs real duration.
|
||||
const estimatedSeconds = Math.max(
|
||||
1,
|
||||
Math.ceil(loaded.buffer.byteLength / 16000),
|
||||
);
|
||||
const usageType = `openai:${selectedModel}:second`;
|
||||
const ucentsPerSecond = SPEECH_TO_TEXT_COSTS[usageType] ?? 0;
|
||||
const estimatedCost = ucentsPerSecond * estimatedSeconds;
|
||||
const allowed = await this.deps.metering.hasEnoughCredits(
|
||||
actor,
|
||||
estimatedCost,
|
||||
);
|
||||
if (!allowed)
|
||||
throw new HttpError(402, 'Insufficient credits', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
|
||||
const openaiFile = await toFile(
|
||||
loaded.buffer,
|
||||
loaded.filename,
|
||||
loaded.mimeType ? { type: loaded.mimeType } : undefined,
|
||||
);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
file: openaiFile,
|
||||
model: selectedModel,
|
||||
};
|
||||
if (args.response_format)
|
||||
payload.response_format = args.response_format;
|
||||
if (args.language) payload.language = args.language;
|
||||
if (typeof args.temperature === 'number')
|
||||
payload.temperature = args.temperature;
|
||||
if (args.prompt && caps.canPrompt) payload.prompt = args.prompt;
|
||||
if (args.logprobs && caps.canLogprobs) payload.logprobs = args.logprobs;
|
||||
if (args.timestamp_granularities && caps.timestampGranularities) {
|
||||
payload.timestamp_granularities = args.timestamp_granularities;
|
||||
}
|
||||
if (caps.diarization) {
|
||||
if (!args.response_format)
|
||||
payload.response_format = 'diarized_json';
|
||||
const needsChunking =
|
||||
caps.requiresChunkingOverThirtySeconds && estimatedSeconds > 30;
|
||||
const strategy =
|
||||
args.chunking_strategy ?? (needsChunking ? 'auto' : undefined);
|
||||
if (strategy) payload.chunking_strategy = strategy;
|
||||
|
||||
if (args.known_speaker_names || args.known_speaker_references) {
|
||||
payload.extra_body = {
|
||||
...(args.extra_body ?? {}),
|
||||
...(args.known_speaker_names
|
||||
? { known_speaker_names: args.known_speaker_names }
|
||||
: {}),
|
||||
...(args.known_speaker_references
|
||||
? {
|
||||
known_speaker_references:
|
||||
args.known_speaker_references,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} else if (args.extra_body) {
|
||||
payload.extra_body = args.extra_body;
|
||||
}
|
||||
|
||||
const result = translate
|
||||
? await this.#openai.audio.translations.create(
|
||||
payload as Parameters<
|
||||
OpenAI['audio']['translations']['create']
|
||||
>[0],
|
||||
)
|
||||
: await this.#openai.audio.transcriptions.create(
|
||||
payload as Parameters<
|
||||
OpenAI['audio']['transcriptions']['create']
|
||||
>[0],
|
||||
);
|
||||
|
||||
this.deps.metering.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
estimatedSeconds,
|
||||
ucentsPerSecond * estimatedSeconds,
|
||||
);
|
||||
|
||||
// Text response_format: return raw string; otherwise forward the OpenAI object.
|
||||
if (args.response_format === 'text') {
|
||||
return typeof result === 'string'
|
||||
? result
|
||||
: ((result as { text?: string }).text ?? '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+42
-27
@@ -18,12 +18,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for XAISpeechToTextDriver.
|
||||
* Offline unit tests for the xAI speech-to-text provider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) configured with an xAI API key, then drives
|
||||
* `server.drivers.aiSpeech2TxtXai` directly. xAI has no SDK — the
|
||||
* driver calls the REST `/v1/stt` endpoint via `fetch` — so global
|
||||
* redis) configured with an xAI API key, then drives the unified
|
||||
* `puter-speech2txt` driver with `provider: 'xai'`. xAI has no SDK —
|
||||
* the provider calls the REST `/v1/stt` endpoint via `fetch` — so global
|
||||
* `fetch` is spied for each request shape assertion. Audio inputs use
|
||||
* `data:` URLs through the live `loadFileInput`, and the FS-resolution
|
||||
* branch is exercised by writing a real file via `server.services.fs.write`.
|
||||
@@ -42,13 +42,13 @@ import {
|
||||
} 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 type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { XAISpeechToTextDriver } from './XAISpeechToTextDriver.js';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { runWithContext } from '../../../../core/context.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
|
||||
import type { SpeechToTextDriver } from '../../SpeechToTextDriver.js';
|
||||
|
||||
// Mirror the driver's per-second cost so test expectations stay in lockstep
|
||||
// with the real value rather than restating an arbitrary literal.
|
||||
@@ -57,7 +57,17 @@ const UCENTS_PER_SECOND = 2778;
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: XAISpeechToTextDriver;
|
||||
let sttDriver: SpeechToTextDriver;
|
||||
|
||||
// Every call below goes through the unified driver pinned to this provider.
|
||||
const driver = {
|
||||
transcribe: (args: Record<string, unknown> = {}) =>
|
||||
sttDriver.transcribe({ ...args, provider: 'xai' }),
|
||||
translate: (args: Record<string, unknown> = {}) =>
|
||||
sttDriver.translate({ ...args, provider: 'xai' }),
|
||||
list_models: () => sttDriver.list_models({ provider: 'xai' }),
|
||||
getReportedCosts: () => sttDriver.getReportedCosts(),
|
||||
};
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
@@ -68,7 +78,7 @@ beforeAll(async () => {
|
||||
xai: { apiKey: 'xai-test-key' },
|
||||
},
|
||||
} as never);
|
||||
driver = server.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
|
||||
sttDriver = server.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -129,9 +139,13 @@ const sttResponse = (body: Record<string, unknown>, status = 200) =>
|
||||
|
||||
// ── getReportedCosts ────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver.getReportedCosts', () => {
|
||||
it('reports a single per-second line item at the documented xAI rate', () => {
|
||||
const reported = driver.getReportedCosts();
|
||||
describe('XAISpeechToTextProvider.getReportedCosts', () => {
|
||||
it('contributes a single per-second line item at the documented xAI rate', () => {
|
||||
// The driver aggregates every provider's catalogue; only the xAI
|
||||
// contribution is this provider's business.
|
||||
const reported = driver
|
||||
.getReportedCosts()
|
||||
.filter((entry) => entry.source === 'driver:aiSpeech2Txt/xai');
|
||||
expect(reported).toEqual([
|
||||
{
|
||||
usageType: 'xai:stt:second',
|
||||
@@ -145,7 +159,7 @@ describe('XAISpeechToTextDriver.getReportedCosts', () => {
|
||||
|
||||
// ── list_models ─────────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver.list_models', () => {
|
||||
describe('XAISpeechToTextProvider.list_models', () => {
|
||||
it('returns the single xai-stt entry with diarization support', async () => {
|
||||
const models = await driver.list_models();
|
||||
expect(models).toHaveLength(1);
|
||||
@@ -163,7 +177,7 @@ describe('XAISpeechToTextDriver.list_models', () => {
|
||||
|
||||
// ── test_mode bypass ────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver test_mode', () => {
|
||||
describe('XAISpeechToTextProvider test_mode', () => {
|
||||
it('returns the canned sample for transcribe, bypassing all I/O', async () => {
|
||||
const result = (await driver.transcribe({
|
||||
file: undefined,
|
||||
@@ -191,7 +205,7 @@ describe('XAISpeechToTextDriver test_mode', () => {
|
||||
|
||||
// ── Argument validation ─────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver argument validation', () => {
|
||||
describe('XAISpeechToTextProvider argument validation', () => {
|
||||
it('throws 400 when file is missing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
@@ -212,7 +226,7 @@ describe('XAISpeechToTextDriver argument validation', () => {
|
||||
|
||||
// ── Missing API key ─────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver missing API key', () => {
|
||||
describe('XAISpeechToTextProvider missing API key', () => {
|
||||
it('throws 500 internal_error when no xAI key is configured', async () => {
|
||||
// Boot a separate server WITHOUT an xAI provider entry so the
|
||||
// driver leaves its key unset. test_mode and validation paths
|
||||
@@ -220,9 +234,10 @@ describe('XAISpeechToTextDriver missing API key', () => {
|
||||
const bareServer = await setupTestServer();
|
||||
try {
|
||||
const bareDriver =
|
||||
bareServer.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
|
||||
bareServer.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver;
|
||||
await expect(
|
||||
bareDriver.transcribe({
|
||||
provider: 'xai',
|
||||
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
@@ -237,7 +252,7 @@ describe('XAISpeechToTextDriver missing API key', () => {
|
||||
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver credit gate', () => {
|
||||
describe('XAISpeechToTextProvider credit gate', () => {
|
||||
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
const { actor } = await makeUser();
|
||||
@@ -285,7 +300,7 @@ describe('XAISpeechToTextDriver credit gate', () => {
|
||||
|
||||
// ── Audio input handling ────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver audio input handling', () => {
|
||||
describe('XAISpeechToTextProvider audio input handling', () => {
|
||||
it('decodes a base64 data URL and POSTs it as the multipart `file` field', async () => {
|
||||
const { actor } = await makeUser();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
@@ -366,7 +381,7 @@ describe('XAISpeechToTextDriver audio input handling', () => {
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver request shape', () => {
|
||||
describe('XAISpeechToTextProvider request shape', () => {
|
||||
it('forwards language / format / diarize / multichannel / channels / audio_format / sample_rate', async () => {
|
||||
const { actor } = await makeUser();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
@@ -438,7 +453,7 @@ describe('XAISpeechToTextDriver request shape', () => {
|
||||
|
||||
// ── Response shape ──────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver response shape', () => {
|
||||
describe('XAISpeechToTextProvider response shape', () => {
|
||||
it('forwards the parsed xAI JSON response verbatim', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const upstream = {
|
||||
@@ -461,7 +476,7 @@ describe('XAISpeechToTextDriver response shape', () => {
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver metering', () => {
|
||||
describe('XAISpeechToTextProvider metering', () => {
|
||||
it('meters ceil(duration) seconds × per-second ucents using the API duration', async () => {
|
||||
const { actor } = await makeUser();
|
||||
// Upstream reports 3.2s → driver should ceil to 4s.
|
||||
@@ -508,7 +523,7 @@ describe('XAISpeechToTextDriver metering', () => {
|
||||
|
||||
// ── Error paths ─────────────────────────────────────────────────────
|
||||
|
||||
describe('XAISpeechToTextDriver error paths', () => {
|
||||
describe('XAISpeechToTextProvider error paths', () => {
|
||||
it('maps upstream 4xx to HttpError 400 upstream_bad_request and skips metering', async () => {
|
||||
const { actor } = await makeUser();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
+43
-72
@@ -3,33 +3,38 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { loadFileInput } from '../util/fileInput.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { loadFileInput } from '../../../util/fileInput.js';
|
||||
import type {
|
||||
ISpeechToTextDeps,
|
||||
ISpeechToTextModel,
|
||||
ITranscribeArgs,
|
||||
} from '../../types.js';
|
||||
import { SpeechToTextProvider } from '../SpeechToTextProvider.js';
|
||||
|
||||
/**
|
||||
* Driver implementing `puter-speech2txt` for the xAI (Grok) STT API.
|
||||
* Speech-to-text via the xAI (Grok) STT API.
|
||||
*
|
||||
* Uses the xAI /v1/stt REST endpoint which accepts multipart/form-data
|
||||
* with an audio file and returns a JSON transcript with word-level timestamps.
|
||||
* Uses the xAI /v1/stt REST endpoint which accepts multipart/form-data with an
|
||||
* audio file and returns a JSON transcript with word-level timestamps.
|
||||
*
|
||||
* Pricing: $0.10/hr REST = 10 cents/hr = 10 * 1_000_000 / 3600 ≈ 2778 microcents/second
|
||||
* Pricing: $0.10/hr REST = 10 cents/hr = 10 * 1_000_000 / 3600 ≈ 2778
|
||||
* microcents/second
|
||||
*/
|
||||
|
||||
const API_BASE = 'https://api.x.ai/v1';
|
||||
@@ -52,28 +57,17 @@ const SAMPLE_TRANSCRIPT = {
|
||||
],
|
||||
};
|
||||
|
||||
interface TranscribeArgs {
|
||||
file: unknown;
|
||||
language?: string;
|
||||
format?: boolean;
|
||||
diarize?: boolean;
|
||||
multichannel?: boolean;
|
||||
channels?: number;
|
||||
audio_format?: string;
|
||||
sample_rate?: number;
|
||||
test_mode?: boolean;
|
||||
}
|
||||
export class XAISpeechToTextProvider extends SpeechToTextProvider {
|
||||
readonly providerName = 'xai';
|
||||
|
||||
export class XAISpeechToTextDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-speech2txt';
|
||||
readonly driverName = 'xai-speech2txt';
|
||||
// Null when the deployment has no xAI credentials. The provider still
|
||||
// registers so its model catalogue stays listable; transcription rejects.
|
||||
#apiKey: string | null;
|
||||
|
||||
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
|
||||
// Shares the `puter-speech2txt` per-user bucket with SpeechToTextDriver.
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
#apiKey: string | null = null;
|
||||
constructor(deps: ISpeechToTextDeps, config: { apiKey?: string }) {
|
||||
super(deps);
|
||||
this.#apiKey = config.apiKey ?? null;
|
||||
}
|
||||
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
return [
|
||||
@@ -86,22 +80,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
];
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
const providers = (this.config.providers ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
>;
|
||||
const xai = providers['xai'];
|
||||
if (!xai) return;
|
||||
const key =
|
||||
(xai.apiKey as string | undefined) ??
|
||||
(xai.secret_key as string | undefined) ??
|
||||
(xai.api_key as string | undefined) ??
|
||||
(xai.key as string | undefined);
|
||||
if (key) this.#apiKey = key;
|
||||
}
|
||||
|
||||
async list_models() {
|
||||
async listModels(): Promise<ISpeechToTextModel[]> {
|
||||
return [
|
||||
{
|
||||
id: 'xai-stt',
|
||||
@@ -115,11 +94,11 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
];
|
||||
}
|
||||
|
||||
async transcribe(args: TranscribeArgs) {
|
||||
async transcribe(args: ITranscribeArgs) {
|
||||
return this.#handleTranscription(args);
|
||||
}
|
||||
|
||||
async translate(args: TranscribeArgs) {
|
||||
async translate(args: ITranscribeArgs) {
|
||||
// xAI STT doesn't have a separate translation endpoint;
|
||||
// delegate to transcribe which auto-detects language
|
||||
return this.#handleTranscription(args);
|
||||
@@ -132,7 +111,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
async #handleTranscription(args: TranscribeArgs) {
|
||||
async #handleTranscription(args: ITranscribeArgs) {
|
||||
if (args.test_mode) {
|
||||
return { ...SAMPLE_TRANSCRIPT, model: 'xai-stt' };
|
||||
}
|
||||
@@ -142,17 +121,9 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
}
|
||||
if (!args.file) {
|
||||
throw new HttpError(400, '`file` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
this.requireFile(args);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
const actor = this.requireActor();
|
||||
|
||||
// Determine if the input is an HTTP URL or a filesystem/data-URL reference
|
||||
const isUrl = this.#isHttpUrl(args.file);
|
||||
@@ -165,8 +136,8 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
|
||||
if (!isUrl) {
|
||||
const loaded = await loadFileInput(
|
||||
this.stores,
|
||||
this.services.fs,
|
||||
this.deps.stores,
|
||||
this.deps.fs,
|
||||
actor,
|
||||
args.file,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE },
|
||||
@@ -183,7 +154,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
? Math.max(1, Math.ceil(fileBuffer.byteLength / 16000))
|
||||
: 60;
|
||||
const estimatedCost = UCENTS_PER_SECOND * estimatedSeconds;
|
||||
const allowed = await this.services.metering.hasEnoughCredits(
|
||||
const allowed = await this.deps.metering.hasEnoughCredits(
|
||||
actor,
|
||||
estimatedCost,
|
||||
);
|
||||
@@ -226,7 +197,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
console.error(
|
||||
`[XAISpeechToTextDriver] API returned ${response.status}: ${errText}`,
|
||||
`[XAISpeechToTextProvider] API returned ${response.status}: ${errText}`,
|
||||
);
|
||||
// Mirrors ElevenLabs / XAITTS — map upstream status to an
|
||||
// `upstream_*` HttpError so the alarm gate skips it.
|
||||
@@ -266,7 +237,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
|
||||
: estimatedSeconds;
|
||||
const actualCost = UCENTS_PER_SECOND * actualSeconds;
|
||||
|
||||
this.services.metering.incrementUsage(
|
||||
this.deps.metering.incrementUsage(
|
||||
actor,
|
||||
'xai:stt:second',
|
||||
actualSeconds,
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
/** Types for the `puter-speech2txt` driver interface. */
|
||||
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import type { loadFileInput } from '../util/fileInput.js';
|
||||
|
||||
/** The layers a provider needs to read its audio input and meter usage. */
|
||||
export interface ISpeechToTextDeps {
|
||||
stores: Parameters<typeof loadFileInput>[0];
|
||||
fs: Parameters<typeof loadFileInput>[1];
|
||||
metering: MeteringService;
|
||||
}
|
||||
|
||||
export interface ISpeechToTextModel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
response_formats: string[];
|
||||
supports_prompt: boolean;
|
||||
supports_logprobs: boolean;
|
||||
supports_diarization?: boolean;
|
||||
supports_timestamp_granularities?: boolean;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface ITranscribeArgs {
|
||||
file?: unknown;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
response_format?: string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
temperature?: number;
|
||||
logprobs?: boolean;
|
||||
timestamp_granularities?: string[];
|
||||
chunking_strategy?: string;
|
||||
known_speaker_names?: string[];
|
||||
known_speaker_references?: unknown[];
|
||||
extra_body?: Record<string, unknown>;
|
||||
stream?: boolean;
|
||||
test_mode?: boolean;
|
||||
// Accepted by the xAI provider only.
|
||||
format?: boolean;
|
||||
diarize?: boolean;
|
||||
multichannel?: boolean;
|
||||
channels?: number;
|
||||
audio_format?: string;
|
||||
sample_rate?: number;
|
||||
}
|
||||
|
||||
export interface ISpeechToTextProvider {
|
||||
readonly providerName: string;
|
||||
|
||||
/** Models this provider exposes. */
|
||||
listModels(): Promise<ISpeechToTextModel[]>;
|
||||
|
||||
/** Transcribe audio in its own language. */
|
||||
transcribe(args: ITranscribeArgs): Promise<unknown>;
|
||||
|
||||
/** Transcribe audio, translating it to English. */
|
||||
translate(args: ITranscribeArgs): Promise<unknown>;
|
||||
|
||||
/** Per-unit metering costs, aggregated by the driver. */
|
||||
getReportedCosts(): Record<string, unknown>[];
|
||||
}
|
||||
@@ -128,6 +128,7 @@ beforeAll(async () => {
|
||||
},
|
||||
gemini: { apiKey: 'gem-key' },
|
||||
xai: { apiKey: 'xai-key' },
|
||||
speechify: { apiKey: 'speechify-key' },
|
||||
},
|
||||
} as never);
|
||||
driver = server.drivers.aiTts as unknown as TTSDriver;
|
||||
@@ -191,6 +192,7 @@ describe('TTSDriver provider registration', () => {
|
||||
'elevenlabs',
|
||||
'gemini',
|
||||
'openai',
|
||||
'speechify',
|
||||
'xai',
|
||||
]);
|
||||
});
|
||||
@@ -243,6 +245,28 @@ describe('TTSDriver.synthesize provider routing', () => {
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes via legacy driverAlias (speechify-tts → speechify)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
audio_data: Buffer.from('audio').toString('base64'),
|
||||
audio_format: 'mp3',
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
|
||||
await withDriverName('speechify-tts', () =>
|
||||
driver.synthesize({ text: 'hi' }),
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
|
||||
'https://api.speechify.ai/v1/audio/speech',
|
||||
);
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes via legacy driverAlias (aws-polly → aws-polly)', async () => {
|
||||
pollyDispatch();
|
||||
|
||||
@@ -257,12 +281,81 @@ describe('TTSDriver.synthesize provider routing', () => {
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to openai when no provider hint is supplied (preferred first)', async () => {
|
||||
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
|
||||
it('defaults to aws-polly when no provider hint is supplied', async () => {
|
||||
pollyDispatch();
|
||||
|
||||
await withActor(() => driver.synthesize({ text: 'hi' }));
|
||||
|
||||
const synthCalls = pollySendMock.mock.calls.filter(
|
||||
([cmd]) => cmd.constructor.name === 'SynthesizeSpeechCommand',
|
||||
);
|
||||
expect(synthCalls).toHaveLength(1);
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['speechify', 'speechify-tts', 'simba'])(
|
||||
'resolves the %s alias to the speechify provider',
|
||||
async (provider) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
audio_data: Buffer.from('audio').toString('base64'),
|
||||
audio_format: 'mp3',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await withActor(() => driver.synthesize({ text: 'hi', provider }));
|
||||
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toMatch(
|
||||
/api\.speechify\.ai/,
|
||||
);
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves provider aliases', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response('audio', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'audio/mpeg' },
|
||||
}),
|
||||
);
|
||||
|
||||
await withActor(() =>
|
||||
driver.synthesize({ text: 'hi', provider: '11labs' }),
|
||||
);
|
||||
|
||||
expect(String(fetchSpy.mock.calls[0]![0])).toMatch(
|
||||
/api\.elevenlabs\.io\/v1\/text-to-speech\//,
|
||||
);
|
||||
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an engine that names a provider as the provider', async () => {
|
||||
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
|
||||
|
||||
await withActor(() => driver.synthesize({ text: 'hi', engine: 'openai' }));
|
||||
|
||||
expect(openaiSpeechCreateMock).toHaveBeenCalledTimes(1);
|
||||
// The alias selected the provider; it is not carried on as a model.
|
||||
expect(openaiSpeechCreateMock.mock.calls[0][0].model).toBe(
|
||||
'gpt-4o-mini-tts',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps `engine` onto `model` for the model-based providers', async () => {
|
||||
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
|
||||
|
||||
await withActor(() =>
|
||||
driver.synthesize({ text: 'hi', provider: 'openai', engine: 'tts-1' }),
|
||||
);
|
||||
|
||||
expect(openaiSpeechCreateMock.mock.calls[0][0].model).toBe('tts-1');
|
||||
});
|
||||
|
||||
it('throws 400 when the named provider is not registered', async () => {
|
||||
@@ -280,7 +373,7 @@ describe('TTSDriver.synthesize provider routing', () => {
|
||||
// ── list_voices / list_engines aggregation ─────────────────────────
|
||||
|
||||
describe('TTSDriver list_voices / list_engines', () => {
|
||||
it('list_voices() aggregates across providers when no filter is supplied', async () => {
|
||||
it("list_voices({ provider: 'all' }) aggregates across providers", async () => {
|
||||
// Provider listVoices methods that touch the network: ElevenLabs
|
||||
// (fetch) and AWS Polly (DescribeVoices). Wire both up.
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
@@ -288,13 +381,14 @@ describe('TTSDriver list_voices / list_engines', () => {
|
||||
);
|
||||
pollyDispatch();
|
||||
|
||||
const voices = await driver.list_voices();
|
||||
const voices = await driver.list_voices({ provider: 'all' });
|
||||
const providers = new Set(voices.map((v) => v.provider));
|
||||
// OpenAI, Gemini, xAI, AWS Polly are all hard-coded catalogs; we
|
||||
// expect those at minimum (ElevenLabs returned an empty list).
|
||||
expect(providers.has('openai')).toBe(true);
|
||||
expect(providers.has('gemini')).toBe(true);
|
||||
expect(providers.has('xai')).toBe(true);
|
||||
expect(providers.has('speechify')).toBe(true);
|
||||
expect(providers.has('aws-polly')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -306,13 +400,23 @@ describe('TTSDriver list_voices / list_engines', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('list_voices({ provider }) returns empty when the provider is not registered', async () => {
|
||||
const voices = await driver.list_voices({ provider: 'nope' });
|
||||
expect(voices).toEqual([]);
|
||||
it('list_voices({ provider }) throws 400 when the provider is unknown', async () => {
|
||||
await expect(driver.list_voices({ provider: 'nope' })).rejects.toMatchObject(
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
});
|
||||
|
||||
it('list_engines() aggregates engines across providers and namespaces them', async () => {
|
||||
const engines = await driver.list_engines();
|
||||
it('list_voices() defaults to aws-polly', async () => {
|
||||
pollyDispatch();
|
||||
const voices = await driver.list_voices();
|
||||
expect(voices.length).toBeGreaterThan(0);
|
||||
for (const voice of voices) {
|
||||
expect(voice.provider).toBe('aws-polly');
|
||||
}
|
||||
});
|
||||
|
||||
it("list_engines({ provider: 'all' }) aggregates engines across providers", async () => {
|
||||
const engines = await driver.list_engines({ provider: 'all' });
|
||||
const ids = engines.map((e) => e.id);
|
||||
// Some signature engines from each provider.
|
||||
expect(ids).toEqual(
|
||||
@@ -321,6 +425,7 @@ describe('TTSDriver list_voices / list_engines', () => {
|
||||
'eleven_multilingual_v2', // elevenlabs
|
||||
'gemini-2.5-flash-preview-tts',
|
||||
'xai-tts',
|
||||
'simba-3.2', // speechify
|
||||
'standard', // aws-polly
|
||||
]),
|
||||
);
|
||||
@@ -350,6 +455,7 @@ describe('TTSDriver.getReportedCosts', () => {
|
||||
'driver:aiTts/aws-polly',
|
||||
'driver:aiTts/gemini',
|
||||
'driver:aiTts/xai',
|
||||
'driver:aiTts/speechify',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -22,10 +23,17 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import {
|
||||
DEFAULT_TTS_PROVIDER,
|
||||
normalizeTTSProvider,
|
||||
TTS_DRIVER_ALIASES,
|
||||
TTS_PROVIDERS,
|
||||
} from './providerAliases.js';
|
||||
import { AWSPollyTTSProvider } from './providers/awsPolly/AWSPollyTTSProvider.js';
|
||||
import { ElevenLabsTTSProvider } from './providers/elevenlabs/ElevenLabsTTSProvider.js';
|
||||
import { GeminiTTSProvider } from './providers/gemini/GeminiTTSProvider.js';
|
||||
import { OpenAITTSProvider } from './providers/openai/OpenAITTSProvider.js';
|
||||
import { SpeechifyTTSProvider } from './providers/speechify/SpeechifyTTSProvider.js';
|
||||
import { XAITTSProvider } from './providers/xai/XAITTSProvider.js';
|
||||
import type {
|
||||
ISynthesizeArgs,
|
||||
@@ -37,35 +45,27 @@ import type {
|
||||
/**
|
||||
* Driver implementing the `puter-tts` interface.
|
||||
*
|
||||
* Manages multiple upstream TTS providers (OpenAI, ElevenLabs, AWS Polly)
|
||||
* and handles provider routing, voice/engine aggregation, and speech
|
||||
* synthesis. Each provider is an `ITTSProvider` instantiated from config
|
||||
* on boot.
|
||||
* Manages multiple upstream TTS providers and handles provider routing,
|
||||
* voice/engine aggregation, and speech synthesis. Each provider is an
|
||||
* `ITTSProvider` instantiated from config on boot.
|
||||
*
|
||||
* Provider selection, alias resolution and per-provider option naming all live
|
||||
* here, so a caller only has to name the provider it wants.
|
||||
*/
|
||||
// puter-js still routes TTS via legacy per-provider driver names rather
|
||||
// than passing `{ provider }` in args, so alias the unified driver under
|
||||
// the names the client expects. `#providerFromAlias` normalizes those
|
||||
// aliases to the internal provider keys used by `#providers`.
|
||||
const TTS_ALIASES = [
|
||||
'aws-polly',
|
||||
'openai-tts',
|
||||
'elevenlabs-tts',
|
||||
'gemini-tts',
|
||||
'xai-tts',
|
||||
] as const;
|
||||
type TTSAlias = (typeof TTS_ALIASES)[number];
|
||||
const ALIAS_TO_PROVIDER: Record<TTSAlias, string> = {
|
||||
'aws-polly': 'aws-polly',
|
||||
'openai-tts': 'openai',
|
||||
'elevenlabs-tts': 'elevenlabs',
|
||||
'gemini-tts': 'gemini',
|
||||
'xai-tts': 'xai',
|
||||
};
|
||||
|
||||
/** Opt-in value that widens the list methods out to every provider. */
|
||||
const ALL_PROVIDERS = 'all';
|
||||
|
||||
const isAllProviders = (value: unknown): boolean =>
|
||||
typeof value === 'string' && value.trim().toLowerCase() === ALL_PROVIDERS;
|
||||
|
||||
export class TTSDriver extends PuterDriver {
|
||||
readonly driverInterface = 'puter-tts';
|
||||
readonly driverName = 'ai-tts';
|
||||
readonly driverAliases = [...TTS_ALIASES];
|
||||
// Older SDK bundles name the provider in the driver slot instead of
|
||||
// passing `{ provider }`; `#resolveProvider` reads the requested alias
|
||||
// back off the Context.
|
||||
readonly driverAliases = [...TTS_DRIVER_ALIASES];
|
||||
readonly isDefault = true;
|
||||
|
||||
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
|
||||
@@ -74,13 +74,6 @@ export class TTSDriver extends PuterDriver {
|
||||
|
||||
#providers: Record<string, ITTSProvider> = {};
|
||||
|
||||
/** Resolve a provider name from the alias the caller used, if any. */
|
||||
#providerFromAlias(): string | undefined {
|
||||
const alias = Context.get('driverName') as string | undefined;
|
||||
if (!alias) return undefined;
|
||||
return ALIAS_TO_PROVIDER[alias as TTSAlias];
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
this.#registerProviders();
|
||||
}
|
||||
@@ -88,50 +81,46 @@ export class TTSDriver extends PuterDriver {
|
||||
// -- Interface methods -------------------------------------------
|
||||
|
||||
/**
|
||||
* List all available voices across all configured providers.
|
||||
* List available voices. Defaults to the default provider; pass `provider:
|
||||
* 'all'` to aggregate across every configured provider.
|
||||
*/
|
||||
async list_voices(args?: Record<string, unknown>): Promise<ITTSVoice[]> {
|
||||
const provider =
|
||||
(args?.provider as string | undefined) ?? this.#providerFromAlias();
|
||||
|
||||
if (provider) {
|
||||
const p = this.#providers[provider];
|
||||
if (!p) return [];
|
||||
return p.listVoices(args);
|
||||
const { provider: requested, ...rest } = args ?? {};
|
||||
if (isAllProviders(requested)) {
|
||||
const allVoices: ITTSVoice[] = [];
|
||||
for (const p of Object.values(this.#providers)) {
|
||||
allVoices.push(...(await p.listVoices(rest)));
|
||||
}
|
||||
return allVoices;
|
||||
}
|
||||
|
||||
const allVoices: ITTSVoice[] = [];
|
||||
for (const p of Object.values(this.#providers)) {
|
||||
const voices = await p.listVoices(args);
|
||||
allVoices.push(...voices);
|
||||
}
|
||||
return allVoices;
|
||||
const p =
|
||||
this.#providers[this.#resolveProvider({ provider: requested })];
|
||||
if (!p) return [];
|
||||
return p.listVoices(rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available engines/models across all configured providers.
|
||||
* List available engines/models. Defaults to the default provider; pass
|
||||
* `provider: 'all'` to aggregate across every configured provider.
|
||||
*/
|
||||
async list_engines(args?: Record<string, unknown>): Promise<ITTSEngine[]> {
|
||||
const provider =
|
||||
(args?.provider as string | undefined) ?? this.#providerFromAlias();
|
||||
|
||||
if (provider) {
|
||||
const p = this.#providers[provider];
|
||||
if (!p) return [];
|
||||
return p.listEngines();
|
||||
const requested = args?.provider;
|
||||
if (isAllProviders(requested)) {
|
||||
const allEngines: ITTSEngine[] = [];
|
||||
for (const p of Object.values(this.#providers)) {
|
||||
allEngines.push(...(await p.listEngines()));
|
||||
}
|
||||
return allEngines;
|
||||
}
|
||||
|
||||
const allEngines: ITTSEngine[] = [];
|
||||
for (const p of Object.values(this.#providers)) {
|
||||
const engines = await p.listEngines();
|
||||
allEngines.push(...engines);
|
||||
}
|
||||
return allEngines;
|
||||
const p =
|
||||
this.#providers[this.#resolveProvider({ provider: requested })];
|
||||
if (!p) return [];
|
||||
return p.listEngines();
|
||||
}
|
||||
|
||||
/**
|
||||
* List provider names that are currently configured.
|
||||
*/
|
||||
/** List provider names that are currently configured. */
|
||||
async list(): Promise<string[]> {
|
||||
return Object.keys(this.#providers);
|
||||
}
|
||||
@@ -157,9 +146,8 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize speech from text. Routes to the appropriate provider
|
||||
* based on the `provider` argument, or falls back to the first
|
||||
* available provider.
|
||||
* Synthesize speech from text, routed to the provider named by `provider`
|
||||
* (or the default when none is given).
|
||||
*/
|
||||
async synthesize(
|
||||
args: ISynthesizeArgs,
|
||||
@@ -170,30 +158,89 @@ export class TTSDriver extends PuterDriver {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
|
||||
const providerName =
|
||||
args.provider ||
|
||||
this.#providerFromAlias() ||
|
||||
this.#getDefaultProviderName();
|
||||
if (!providerName) {
|
||||
throw new HttpError(500, 'No TTS providers configured', {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
}
|
||||
|
||||
const providerName = this.#resolveProvider(args);
|
||||
const provider = this.#providers[providerName];
|
||||
if (!provider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`TTS provider not found: ${providerName}. Available: ${Object.keys(this.#providers).join(', ')}`,
|
||||
`TTS provider not configured: ${providerName}. Available: ${Object.keys(this.#providers).join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
return provider.synthesize(args) as Promise<
|
||||
return provider.synthesize(
|
||||
this.#providerArgs(providerName, args),
|
||||
) as Promise<
|
||||
DriverStreamResult | { url: string; content_type: string }
|
||||
>;
|
||||
}
|
||||
|
||||
// -- Provider routing --------------------------------------------
|
||||
|
||||
/**
|
||||
* Decide which provider handles a call. An explicit `provider` wins, then
|
||||
* an `engine` that names a provider (a long-standing shorthand), then the
|
||||
* legacy driver alias the caller dispatched through, then the default.
|
||||
*/
|
||||
#resolveProvider(args: { provider?: unknown; engine?: unknown }): string {
|
||||
if (
|
||||
args.provider !== undefined &&
|
||||
args.provider !== null &&
|
||||
args.provider !== ''
|
||||
) {
|
||||
const named = normalizeTTSProvider(args.provider);
|
||||
if (!named) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`TTS provider not found: ${String(args.provider)}. Available: ${TTS_PROVIDERS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return named;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeTTSProvider(args.engine) ??
|
||||
normalizeTTSProvider(Context.get('driverName')) ??
|
||||
this.#defaultProvider()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `engine` is AWS Polly's own concept; on the model-based providers it is
|
||||
* the legacy spelling of `model`.
|
||||
*/
|
||||
#providerArgs(
|
||||
providerName: string,
|
||||
args: ISynthesizeArgs,
|
||||
): ISynthesizeArgs {
|
||||
if (providerName === 'aws-polly') {
|
||||
return { ...args, provider: providerName };
|
||||
}
|
||||
const { engine, ...rest } = args;
|
||||
if (
|
||||
rest.model === undefined &&
|
||||
typeof engine === 'string' &&
|
||||
// An engine that named the provider selected it above; it is not
|
||||
// also a model id.
|
||||
!normalizeTTSProvider(engine)
|
||||
) {
|
||||
rest.model = engine;
|
||||
}
|
||||
return { ...rest, provider: providerName };
|
||||
}
|
||||
|
||||
/**
|
||||
* The documented default, falling back to whatever is configured so a
|
||||
* deployment without AWS Polly still serves TTS.
|
||||
*/
|
||||
#defaultProvider(): string {
|
||||
for (const name of [DEFAULT_TTS_PROVIDER, ...TTS_PROVIDERS]) {
|
||||
if (this.#providers[name]) return name;
|
||||
}
|
||||
return Object.keys(this.#providers)[0] ?? DEFAULT_TTS_PROVIDER;
|
||||
}
|
||||
|
||||
// -- Provider registration ---------------------------------------
|
||||
|
||||
#registerProviders() {
|
||||
@@ -271,6 +318,7 @@ export class TTSDriver extends PuterDriver {
|
||||
|
||||
this.#registerGeminiProvider(providers);
|
||||
this.#registerXAIProvider(providers);
|
||||
this.#registerSpeechifyProvider(providers);
|
||||
}
|
||||
|
||||
#registerGeminiProvider(providers: Record<string, unknown>) {
|
||||
@@ -319,13 +367,25 @@ export class TTSDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
#getDefaultProviderName(): string | null {
|
||||
const names = Object.keys(this.#providers);
|
||||
if (names.length === 0) return null;
|
||||
// Prefer openai, then elevenlabs, then gemini, then aws-polly
|
||||
if (this.#providers['openai']) return 'openai';
|
||||
if (this.#providers['elevenlabs']) return 'elevenlabs';
|
||||
if (this.#providers['gemini']) return 'gemini';
|
||||
return names[0];
|
||||
#registerSpeechifyProvider(providers: Record<string, unknown>) {
|
||||
const m = this.services.metering;
|
||||
const speechify = (providers['speechify'] ??
|
||||
providers['speechify-tts']) as Record<string, unknown> | undefined;
|
||||
const speechifyKey =
|
||||
(speechify?.apiKey as string | undefined) ??
|
||||
(speechify?.api_key as string | undefined) ??
|
||||
(speechify?.key as string | undefined);
|
||||
if (speechifyKey) {
|
||||
try {
|
||||
this.#providers['speechify'] = new SpeechifyTTSProvider(m, {
|
||||
apiKey: speechifyKey,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[TTSDriver] Failed to init Speechify TTS provider:',
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canonical `puter-tts` provider ids and the aliases callers may use in their
|
||||
* place. Resolution lives here rather than in the SDK so a new alias reaches
|
||||
* every caller at once, including clients running an older bundle.
|
||||
*/
|
||||
|
||||
export const TTS_PROVIDERS = [
|
||||
'aws-polly',
|
||||
'openai',
|
||||
'elevenlabs',
|
||||
'gemini',
|
||||
'xai',
|
||||
'speechify',
|
||||
] as const;
|
||||
|
||||
export type TTSProviderName = (typeof TTS_PROVIDERS)[number];
|
||||
|
||||
/** The documented default when a caller names no provider. */
|
||||
export const DEFAULT_TTS_PROVIDER: TTSProviderName = 'aws-polly';
|
||||
|
||||
/**
|
||||
* Driver names the unified driver answers to. Older SDK bundles put the
|
||||
* provider in the driver slot instead of passing `{ provider }`.
|
||||
*/
|
||||
export const TTS_DRIVER_ALIASES = [
|
||||
'aws-polly',
|
||||
'openai-tts',
|
||||
'elevenlabs-tts',
|
||||
'gemini-tts',
|
||||
'xai-tts',
|
||||
'speechify-tts',
|
||||
] as const;
|
||||
|
||||
const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
|
||||
aws: 'aws-polly',
|
||||
'aws-polly': 'aws-polly',
|
||||
polly: 'aws-polly',
|
||||
openai: 'openai',
|
||||
'openai-tts': 'openai',
|
||||
'11-labs': 'elevenlabs',
|
||||
'11labs': 'elevenlabs',
|
||||
eleven: 'elevenlabs',
|
||||
'eleven-labs': 'elevenlabs',
|
||||
elevenlabs: 'elevenlabs',
|
||||
'elevenlabs-tts': 'elevenlabs',
|
||||
gemini: 'gemini',
|
||||
'gemini-tts': 'gemini',
|
||||
google: 'gemini',
|
||||
'google-tts': 'gemini',
|
||||
grok: 'xai',
|
||||
'grok-tts': 'xai',
|
||||
'x-ai': 'xai',
|
||||
xai: 'xai',
|
||||
'xai-tts': 'xai',
|
||||
simba: 'speechify',
|
||||
speechify: 'speechify',
|
||||
'speechify-tts': 'speechify',
|
||||
};
|
||||
|
||||
/** Resolve a caller-supplied provider name, or `undefined` if unrecognized. */
|
||||
export function normalizeTTSProvider(
|
||||
value: unknown,
|
||||
): TTSProviderName | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return PROVIDER_BY_ALIAS[value.trim().toLowerCase()];
|
||||
}
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -37,14 +38,23 @@ const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
|
||||
|
||||
const VALID_ENGINES = ['standard', 'neural', 'long-form', 'generative'];
|
||||
|
||||
// Voices tried, in order, when the caller names none. The head of each list
|
||||
// is the provider's advertised default.
|
||||
const PREFERRED_VOICES: Record<string, string[]> = {
|
||||
standard: ['Joanna', 'Salli', 'Matthew'],
|
||||
neural: ['Joanna', 'Matthew', 'Salli'],
|
||||
'long-form': ['Joanna', 'Matthew'],
|
||||
generative: ['Joanna', 'Matthew', 'Salli'],
|
||||
};
|
||||
|
||||
interface PollyVoicesResponse {
|
||||
Voices: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AWS Polly TTS provider. Wraps the AWS Polly speech synthesis API and
|
||||
* returns audio as a DriverStreamResult. Includes voice caching and
|
||||
* engine-aware voice selection.
|
||||
* AWS Polly TTS provider. Wraps the AWS Polly speech synthesis API and returns
|
||||
* audio as a DriverStreamResult. Includes voice caching and engine-aware voice
|
||||
* selection.
|
||||
*/
|
||||
export class AWSPollyTTSProvider extends TTSProvider {
|
||||
readonly providerName = 'aws-polly';
|
||||
@@ -111,25 +121,29 @@ export class AWSPollyTTSProvider extends TTSProvider {
|
||||
): Promise<string | null> {
|
||||
const voices = await this.describeVoices();
|
||||
|
||||
const voice = voices.Voices.find(
|
||||
const candidates = voices.Voices.filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(v: any) =>
|
||||
v.LanguageCode === language &&
|
||||
v.SupportedEngines?.includes(engine),
|
||||
);
|
||||
return voice ? voice.Id : null;
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Keep the engine's default voice when it speaks the requested
|
||||
// language, so naming a language doesn't silently change the voice
|
||||
// for callers who only wanted the default.
|
||||
for (const voiceName of PREFERRED_VOICES[engine] ?? []) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const match = candidates.find((v: any) => v.Id === voiceName);
|
||||
if (match) return match.Id;
|
||||
}
|
||||
return candidates[0].Id;
|
||||
}
|
||||
|
||||
private async getDefaultVoiceForEngine(engine: string): Promise<string> {
|
||||
const voices = await this.describeVoices();
|
||||
|
||||
const defaultVoices: Record<string, string[]> = {
|
||||
standard: ['Salli', 'Joanna', 'Matthew'],
|
||||
neural: ['Joanna', 'Matthew', 'Salli'],
|
||||
'long-form': ['Joanna', 'Matthew'],
|
||||
generative: ['Joanna', 'Matthew', 'Salli'],
|
||||
};
|
||||
|
||||
const preferred = defaultVoices[engine] || ['Salli'];
|
||||
const preferred = PREFERRED_VOICES[engine] ?? ['Salli'];
|
||||
|
||||
for (const voiceName of preferred) {
|
||||
const voice = voices.Voices.find(
|
||||
@@ -164,7 +178,7 @@ export class AWSPollyTTSProvider extends TTSProvider {
|
||||
400,
|
||||
`Invalid engine: ${engine}. Valid engines: ${VALID_ENGINES.join(', ')}`,
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
legacyCode: 'invalid_engine',
|
||||
fields: { engine, valid_engines: VALID_ENGINES },
|
||||
},
|
||||
);
|
||||
@@ -225,7 +239,7 @@ export class AWSPollyTTSProvider extends TTSProvider {
|
||||
400,
|
||||
`Invalid engine: ${engine}. Valid engines: ${VALID_ENGINES.join(', ')}`,
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
legacyCode: 'invalid_engine',
|
||||
fields: { engine, valid_engines: VALID_ENGINES },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for SpeechifyTTSProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs SpeechifyTTSProvider directly against the live
|
||||
* wired `MeteringService`. Speechify has no SDK here — the provider
|
||||
* calls the REST endpoint via `fetch` — so global `fetch` is spied for
|
||||
* each request shape assertion.
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { SpeechifyTTSProvider } from './SpeechifyTTSProvider.js';
|
||||
import { SPEECHIFY_TTS_COSTS } from './costs.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = () =>
|
||||
new SpeechifyTTSProvider(server.services.metering, { apiKey: 'test-key' });
|
||||
|
||||
const audioResponse = (audioData = Buffer.from('audio-bytes').toString('base64'), audioFormat = 'mp3') =>
|
||||
new Response(JSON.stringify({ audio_data: audioData, audio_format: audioFormat }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider construction', () => {
|
||||
it('throws when no apiKey is supplied', () => {
|
||||
expect(
|
||||
() =>
|
||||
new SpeechifyTTSProvider(server.services.metering, {
|
||||
apiKey: '',
|
||||
}),
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Voice / engine catalog ──────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider catalog', () => {
|
||||
it('listVoices returns the documented Speechify voices with provider=speechify', async () => {
|
||||
const provider = makeProvider();
|
||||
const voices = await provider.listVoices();
|
||||
const ids = voices.map((v) => v.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining(['geffen_32', 'dominic_32', 'harper_32', 'hugh_32', 'imogen_32']),
|
||||
);
|
||||
for (const voice of voices) {
|
||||
expect(voice.provider).toBe('speechify');
|
||||
}
|
||||
});
|
||||
|
||||
it('listEngines reports the Simba model family', async () => {
|
||||
const provider = makeProvider();
|
||||
const engines = await provider.listEngines();
|
||||
const ids = engines.map((e) => e.id);
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining(['simba-3.2', 'simba-english', 'simba-multilingual']),
|
||||
);
|
||||
for (const engine of engines) {
|
||||
expect(engine.provider).toBe('speechify');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reported costs ──────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.getReportedCosts', () => {
|
||||
it('mirrors every entry in costs.ts as a per-character line item', () => {
|
||||
const provider = makeProvider();
|
||||
const reported = provider.getReportedCosts();
|
||||
expect(reported).toHaveLength(Object.keys(SPEECHIFY_TTS_COSTS).length);
|
||||
for (const [model, ucentsPerUnit] of Object.entries(SPEECHIFY_TTS_COSTS)) {
|
||||
expect(reported).toContainEqual({
|
||||
usageType: `speechify:${model}:character`,
|
||||
ucentsPerUnit,
|
||||
unit: 'character',
|
||||
source: 'driver:aiTts/speechify',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── test_mode bypass ────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize test_mode', () => {
|
||||
it('returns the canned sample URL without hitting credits or fetch', async () => {
|
||||
const provider = makeProvider();
|
||||
const result = await withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
|
||||
content_type: 'audio',
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Argument validation ─────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize argument validation', () => {
|
||||
it('throws 400 when text is missing or blank', async () => {
|
||||
const provider = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: '' })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: ' ' })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 for an unrecognized model', async () => {
|
||||
const provider = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi', model: 'not-a-real-model' }),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize credit gate', () => {
|
||||
it('throws 402 BEFORE hitting Speechify when actor lacks credits', async () => {
|
||||
const provider = makeProvider();
|
||||
hasCreditsSpy.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 402 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize request shape', () => {
|
||||
it('POSTs to /v1/audio/speech with Bearer auth, Speechify-Caller header, and defaults', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
await withTestActor(() => provider.synthesize({ text: 'hello' }));
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(String(url)).toBe('https://api.speechify.ai/v1/audio/speech');
|
||||
const initObj = init as RequestInit;
|
||||
expect(initObj.method).toBe('POST');
|
||||
const headers = initObj.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer test-key');
|
||||
expect(headers['Speechify-Caller']).toBe('puter');
|
||||
|
||||
const body = JSON.parse(initObj.body as string);
|
||||
expect(body).toEqual({
|
||||
input: '<speak>hello</speak>',
|
||||
voice_id: 'geffen_32', // DEFAULT_VOICE
|
||||
model: 'simba-3.2', // DEFAULT_MODEL
|
||||
audio_format: 'mp3',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards voice and model overrides to the API', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi', voice: 'alec', model: 'simba-english' }),
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(body.voice_id).toBe('alec');
|
||||
expect(body.model).toBe('simba-english');
|
||||
});
|
||||
|
||||
it('does not re-wrap text that is already SSML', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.synthesize({ text: '<speak>already SSML</speak>' }),
|
||||
);
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
|
||||
);
|
||||
expect(body.input).toBe('<speak>already SSML</speak>');
|
||||
});
|
||||
|
||||
it('decodes base64 audio_data into a readable byte stream', async () => {
|
||||
const provider = makeProvider();
|
||||
const raw = Buffer.from('AAA-BBB');
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse(raw.toString('base64')));
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi' }),
|
||||
)) as { stream: Readable; content_type: string; chunked: boolean };
|
||||
|
||||
expect(result.chunked).toBe(true);
|
||||
expect(result.stream).toBeInstanceOf(Readable);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of result.stream) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
expect(Buffer.concat(chunks).equals(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it('maps audio_format to the canonical content-type', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
audioResponse(Buffer.from('x').toString('base64'), 'wav'),
|
||||
);
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.synthesize({ text: 'hi', output_format: 'wav' }),
|
||||
)) as { content_type: string };
|
||||
|
||||
expect(result.content_type).toBe('audio/wav');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cost reporting & metering ───────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize metering', () => {
|
||||
it('meters character count × per-char ucents under speechify:<model>:character', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
const text = 'hello world';
|
||||
await withTestActor(() => provider.synthesize({ text }));
|
||||
|
||||
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
|
||||
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
|
||||
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
|
||||
expect(usageType).toBe('speechify:simba-3.2:character');
|
||||
expect(count).toBe(text.length);
|
||||
expect(cost).toBe(expectedCost);
|
||||
});
|
||||
|
||||
it('asks for hasEnoughCredits with the same total it later meters', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(audioResponse());
|
||||
|
||||
const text = 'hi there';
|
||||
await withTestActor(() => provider.synthesize({ text }));
|
||||
|
||||
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
|
||||
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error paths ─────────────────────────────────────────────────────
|
||||
|
||||
describe('SpeechifyTTSProvider.synthesize error paths', () => {
|
||||
it('maps upstream 4xx to HttpError 400 upstream_bad_request', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response('bad request', { status: 400 }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(new Response('oops', { status: 503 }));
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(new Response('slow down', { status: 429 }));
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 400 when the response has no audio_data', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
|
||||
|
||||
await expect(
|
||||
withTestActor(() => provider.synthesize({ text: 'hi' })),
|
||||
).rejects.toThrow('connection reset');
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { DriverStreamResult } from '../../../meta.js';
|
||||
import type { ITTSVoice, ITTSEngine, ISynthesizeArgs } from '../../types.js';
|
||||
import { TTSProvider } from '../TTSProvider.js';
|
||||
import { SPEECHIFY_TTS_COSTS } from './costs.js';
|
||||
|
||||
// Public API base only — never an internal/consumer Speechify endpoint.
|
||||
const API_BASE = 'https://api.speechify.ai';
|
||||
const CALLER_HEADER = 'Speechify-Caller';
|
||||
const CALLER_VALUE = 'puter';
|
||||
const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
|
||||
|
||||
const DEFAULT_MODEL = 'simba-3.2';
|
||||
const DEFAULT_VOICE = 'geffen_32';
|
||||
|
||||
const SPEECHIFY_TTS_MODELS = [
|
||||
{ id: 'simba-3.2', name: 'Simba 3.2' },
|
||||
{ id: 'simba-english', name: 'Simba English' },
|
||||
{ id: 'simba-multilingual', name: 'Simba Multilingual' },
|
||||
];
|
||||
|
||||
// Representative starter catalog — verify against Speechify's live
|
||||
// voices endpoint before this ships upstream.
|
||||
const SPEECHIFY_TTS_VOICES = [
|
||||
{ id: 'geffen_32', name: 'Geffen', description: 'Warm, conversational' },
|
||||
{ id: 'dominic_32', name: 'Dominic', description: 'Deep, narrator' },
|
||||
{ id: 'harper_32', name: 'Harper', description: 'Bright, upbeat' },
|
||||
{ id: 'hugh_32', name: 'Hugh', description: 'Calm, professional' },
|
||||
{ id: 'imogen_32', name: 'Imogen', description: 'Clear, neutral' },
|
||||
];
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
aac: 'audio/aac',
|
||||
};
|
||||
|
||||
/**
|
||||
* Speechify TTS provider. Calls the Speechify `/v1/audio/speech` REST endpoint
|
||||
* and returns audio as a DriverStreamResult. Every outbound request carries
|
||||
* `Speechify-Caller: puter` for integration attribution.
|
||||
*/
|
||||
export class SpeechifyTTSProvider extends TTSProvider {
|
||||
readonly providerName = 'speechify';
|
||||
|
||||
#apiKey: string;
|
||||
|
||||
constructor(meteringService: MeteringService, config: { apiKey: string }) {
|
||||
super(meteringService, config);
|
||||
if (!config.apiKey) {
|
||||
throw new Error('Speechify TTS requires an API key');
|
||||
}
|
||||
this.#apiKey = config.apiKey;
|
||||
}
|
||||
|
||||
async listVoices(): Promise<ITTSVoice[]> {
|
||||
return SPEECHIFY_TTS_VOICES.map((voice) => ({
|
||||
id: voice.id,
|
||||
name: voice.name,
|
||||
description: voice.description,
|
||||
provider: 'speechify',
|
||||
supported_models: SPEECHIFY_TTS_MODELS.map((m) => m.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async listEngines(): Promise<ITTSEngine[]> {
|
||||
return SPEECHIFY_TTS_MODELS.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
provider: 'speechify',
|
||||
}));
|
||||
}
|
||||
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
return Object.entries(SPEECHIFY_TTS_COSTS).map(
|
||||
([model, ucentsPerUnit]) => ({
|
||||
usageType: `speechify:${model}:character`,
|
||||
ucentsPerUnit,
|
||||
unit: 'character',
|
||||
source: 'driver:aiTts/speechify',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async synthesize(
|
||||
args: ISynthesizeArgs,
|
||||
): Promise<DriverStreamResult | { url: string; content_type: string }> {
|
||||
const {
|
||||
text,
|
||||
voice: voiceArg,
|
||||
model: modelArg,
|
||||
response_format,
|
||||
output_format,
|
||||
test_mode,
|
||||
} = args;
|
||||
|
||||
if (test_mode) {
|
||||
return { url: SAMPLE_AUDIO_URL, content_type: 'audio' };
|
||||
}
|
||||
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new HttpError(400, 'Missing required field: text', {
|
||||
legacyCode: 'field_required',
|
||||
fields: { key: 'text' },
|
||||
});
|
||||
}
|
||||
|
||||
const model = modelArg || DEFAULT_MODEL;
|
||||
if (!SPEECHIFY_TTS_MODELS.find(({ id }) => id === model)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid model: ${model}. Expected: ${SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(', ')}`,
|
||||
{
|
||||
legacyCode: 'field_invalid',
|
||||
fields: {
|
||||
key: 'model',
|
||||
expected: SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(
|
||||
', ',
|
||||
),
|
||||
got: model,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const voice = voiceArg || DEFAULT_VOICE;
|
||||
const format = output_format || response_format || 'mp3';
|
||||
|
||||
const actor = Context.get('actor')!;
|
||||
const usageType = `speechify:${model}:character`;
|
||||
const ucentsPerChar = SPEECHIFY_TTS_COSTS[model] ?? 0;
|
||||
const totalCost = ucentsPerChar * text.length;
|
||||
|
||||
const usageAllowed = await this.meteringService.hasEnoughCredits(
|
||||
actor,
|
||||
totalCost,
|
||||
);
|
||||
if (!usageAllowed) {
|
||||
throw new HttpError(402, 'Insufficient funds', {
|
||||
legacyCode: 'insufficient_funds',
|
||||
});
|
||||
}
|
||||
|
||||
// Speechify's synthesis endpoint expects SSML; wrap plain text so
|
||||
// callers can keep passing bare strings like every other provider.
|
||||
const input = /<speak[\s>]/i.test(text)
|
||||
? text
|
||||
: `<speak>${text}</speak>`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
[CALLER_HEADER]: CALLER_VALUE,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input,
|
||||
voice_id: voice,
|
||||
model,
|
||||
audio_format: format,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
console.error(
|
||||
`[SpeechifyTTSProvider] API returned ${response.status}: ${errText}`,
|
||||
);
|
||||
// Map upstream status to an `upstream_*` HttpError so the
|
||||
// alarm gate skips it. Mirrors ElevenLabs/xAI's translator —
|
||||
// 4xx and 5xx both surface as 400 to the client (with the
|
||||
// appropriate legacyCode), 429 stays 429, auth stays 500.
|
||||
const legacyCode =
|
||||
response.status >= 500
|
||||
? 'upstream_provider_unavailable'
|
||||
: response.status === 401 || response.status === 403
|
||||
? 'upstream_auth_failed'
|
||||
: response.status === 429
|
||||
? 'upstream_rate_limited'
|
||||
: 'upstream_bad_request';
|
||||
const exposedStatus =
|
||||
legacyCode === 'upstream_rate_limited'
|
||||
? 429
|
||||
: legacyCode === 'upstream_auth_failed'
|
||||
? 500
|
||||
: 400;
|
||||
throw new HttpError(
|
||||
exposedStatus,
|
||||
errText ||
|
||||
`Speechify TTS request failed (status ${response.status})`,
|
||||
{
|
||||
legacyCode,
|
||||
fields: {
|
||||
provider: 'speechify',
|
||||
upstreamStatus: response.status,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any = await response.json();
|
||||
if (!data?.audio_data) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Speechify TTS did not return audio data',
|
||||
{
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields: { provider: 'speechify' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data.audio_data, 'base64');
|
||||
const stream = Readable.from(buffer);
|
||||
const contentType =
|
||||
CONTENT_TYPES[data.audio_format ?? format] || 'audio/mpeg';
|
||||
|
||||
this.meteringService.incrementUsage(
|
||||
actor,
|
||||
usageType,
|
||||
text.length,
|
||||
totalCost,
|
||||
);
|
||||
|
||||
return {
|
||||
dataType: 'stream',
|
||||
content_type: contentType,
|
||||
chunked: true,
|
||||
stream,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Speechify TTS pricing: $10.00 per 1M characters across the Simba model
|
||||
// family (per Speechify's published API pricing).
|
||||
// $10.00 per 1M chars = 1000 cents per 1M chars
|
||||
// In microcents: 1000 * 1_000_000 = 1_000_000_000 microcents per 1M chars
|
||||
// Per character: 1_000_000_000 / 1_000_000 = 1000 microcents per character
|
||||
export const SPEECHIFY_TTS_COSTS: Record<string, number> = {
|
||||
'simba-3.2': 1000,
|
||||
'simba-english': 1000,
|
||||
'simba-multilingual': 1000,
|
||||
};
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { Context } from '../../core/context.js';
|
||||
@@ -29,6 +30,17 @@ import {
|
||||
isRawBase64ImageString,
|
||||
normalizeRawBase64ImageString,
|
||||
} from '../../util/appIcon.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
extractPuterHostedSubdomain,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
normalizeLimit,
|
||||
normalizeOffset,
|
||||
} from '../../util/pagination.js';
|
||||
import { resolvePrivateLaunchAccess } from '../../util/privateLaunchAccess.js';
|
||||
import {
|
||||
validateArrayOfStrings,
|
||||
@@ -83,15 +95,16 @@ const hasIndexUrlUniquenessExemption = (candidates) => {
|
||||
/**
|
||||
* Driver exposing the `puter-apps` interface.
|
||||
*
|
||||
* Wraps AppStore with input validation + permission checks.
|
||||
* Methods follow the `crud-q` shape client SDKs expect:
|
||||
* create, read, select, update, upsert, delete
|
||||
* Wraps AppStore with input validation + permission checks. Methods follow the
|
||||
* `crud-q` shape client SDKs expect: create, read, select, update, upsert,
|
||||
* delete
|
||||
*
|
||||
* Permission model:
|
||||
* - Owner (apps.owner_user_id === actor.user.id) has full access
|
||||
* - App actor matching app_owner has full access
|
||||
* - `system:es:write-all-owners` grants blanket write
|
||||
* - `app:uid#<app_uid>:access` grants protected-app access
|
||||
*
|
||||
* - Owner (apps.owner_user_id === actor.user.id) has full access
|
||||
* - App actor matching app_owner has full access
|
||||
* - `system:es:write-all-owners` grants blanket write
|
||||
* - `app:uid#<app_uid>:access` grants protected-app access
|
||||
*/
|
||||
export class AppDriver extends PuterDriver {
|
||||
driverInterface = 'puter-apps';
|
||||
@@ -241,17 +254,46 @@ export class AppDriver extends PuterDriver {
|
||||
return this.#toClient(app, actor, { ...params, stats });
|
||||
}
|
||||
|
||||
async select({ predicate, params = {} } = {}) {
|
||||
async select(args = {}) {
|
||||
const { predicate, params = {} } = args;
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 500;
|
||||
const offset = normalizeOffset(args.offset);
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(args, 'cursor');
|
||||
const payload = decodeCursor(args.cursor);
|
||||
if (payload && offset !== undefined) {
|
||||
throw new HttpError(400, 'cursor and offset cannot be combined', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const includeTotal = args.includeTotal === true;
|
||||
const paginated = hasCursor || offset !== undefined || includeTotal;
|
||||
|
||||
const filters = {};
|
||||
// predicate: ['user-can-edit'] → scope to owner
|
||||
if (Array.isArray(predicate) && predicate[0] === 'user-can-edit') {
|
||||
const ownerScoped =
|
||||
Array.isArray(predicate) && predicate[0] === 'user-can-edit';
|
||||
if (ownerScoped) {
|
||||
filters.ownerUserId = actor.user.id;
|
||||
}
|
||||
|
||||
const apps = await this.appStore.list(filters);
|
||||
let apps = await this.appStore.list({
|
||||
...filters,
|
||||
limit: paginated ? limit + 1 : limit,
|
||||
offset,
|
||||
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
|
||||
});
|
||||
|
||||
let cursor;
|
||||
if (paginated && apps.length > limit) {
|
||||
apps = apps.slice(0, limit);
|
||||
// The cursor tracks the last fetched row, not the last visible
|
||||
// one, so rows hidden by the permission filter below aren't
|
||||
// re-scanned on the next page.
|
||||
cursor = encodeCursor({ id: Number(apps[apps.length - 1].id) });
|
||||
}
|
||||
|
||||
// Resolve protected-app visibility:
|
||||
// 1. Cheap local short-circuits (non-protected, self-app, owner).
|
||||
@@ -302,7 +344,7 @@ export class AppDriver extends PuterDriver {
|
||||
),
|
||||
]);
|
||||
|
||||
return Promise.all(
|
||||
const items = await Promise.all(
|
||||
visible.map((app) =>
|
||||
this.#toClient(app, actor, {
|
||||
...params,
|
||||
@@ -311,6 +353,22 @@ export class AppDriver extends PuterDriver {
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!paginated) return items;
|
||||
|
||||
let total;
|
||||
if (includeTotal) {
|
||||
total = ownerScoped
|
||||
? await this.appStore.count({ ownerUserId: actor.user.id })
|
||||
: await this.appStore.count({
|
||||
visibleToUserId: actor.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async update({ uid, id, object } = {}) {
|
||||
@@ -597,13 +655,13 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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;
|
||||
@@ -673,15 +731,15 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* uid lookup with canonical-uid alias fallback. When two app rows
|
||||
* have been merged (see {@link #maybeJoinOwnedHostedIndexUrlApp}),
|
||||
* the source uid is recorded as an alias to the canonical uid. A
|
||||
* direct uid miss therefore re-queries with the canonical uid so
|
||||
* any client still holding the old uid keeps resolving to the
|
||||
* joined row. Mirrors v1 AppES's `#read` alias plumbing.
|
||||
* Uid lookup with canonical-uid alias fallback. When two app rows have been
|
||||
* merged (see {@link #maybeJoinOwnedHostedIndexUrlApp}), the source uid is
|
||||
* recorded as an alias to the canonical uid. A direct uid miss therefore
|
||||
* re-queries with the canonical uid so any client still holding the old uid
|
||||
* keeps resolving to the joined row. Mirrors v1 AppES's `#read` alias
|
||||
* plumbing.
|
||||
*
|
||||
* The alias query is fired in parallel with the direct lookup so
|
||||
* the common (no-alias) case pays only one round-trip.
|
||||
* The alias query is fired in parallel with the direct lookup so the common
|
||||
* (no-alias) case pays only one round-trip.
|
||||
*/
|
||||
async #getByUidWithAlias(uid) {
|
||||
const aliasPromise = this.#readCanonicalAppUidAlias(uid);
|
||||
@@ -748,23 +806,24 @@ export class AppDriver extends PuterDriver {
|
||||
* Resolve the canonical app row that backs `app.index_url`.
|
||||
*
|
||||
* Returns `{ origin, expectedUid, canonicalApp }`:
|
||||
* - `origin` — the parsed origin string from `index_url`.
|
||||
* - `expectedUid` — the canonical app uid for that origin (oldest
|
||||
* `apps.index_url` match, or a deterministic UUIDv5 fallback for
|
||||
* unknown origins).
|
||||
* - `canonicalApp` — the actual `apps` row at `expectedUid`, or
|
||||
* `null` when the uid is a UUIDv5 fallback with no DB row.
|
||||
*
|
||||
* - `origin` — the parsed origin string from `index_url`.
|
||||
* - `expectedUid` — the canonical app uid for that origin (oldest
|
||||
* `apps.index_url` match, or a deterministic UUIDv5 fallback for unknown
|
||||
* origins).
|
||||
* - `canonicalApp` — the actual `apps` row at `expectedUid`, or `null` when
|
||||
* the uid is a UUIDv5 fallback with no DB row.
|
||||
*
|
||||
* Used in `#toClient` for two things:
|
||||
* 1. `created_from_origin` derivation (only set when
|
||||
* `expectedUid === app.uid`, mirroring v1 AppES).
|
||||
* 2. The canonical-private gate — when `expectedUid !== app.uid`
|
||||
* and `canonicalApp.is_private`, the row is squatting on
|
||||
* someone else's private hosted URL. We must run the
|
||||
* privateAccess gate against the *canonical* row, not the
|
||||
* possibly-public squatter row, otherwise pre-existing data
|
||||
* from before the `subdomain_not_owned` check leaks the
|
||||
* victim's index_url.
|
||||
*
|
||||
* 1. `created_from_origin` derivation (only set when `expectedUid ===
|
||||
* app.uid`, mirroring v1 AppES).
|
||||
* 2. The canonical-private gate — when `expectedUid !== app.uid` and
|
||||
* `canonicalApp.is_private`, the row is squatting on someone else's
|
||||
* private hosted URL. We must run the privateAccess gate against the
|
||||
* _canonical_ row, not the possibly-public squatter row, otherwise
|
||||
* pre-existing data from before the `subdomain_not_owned` check leaks
|
||||
* the victim's index_url.
|
||||
*
|
||||
* Returns `null` when there's no `index_url` or it doesn't parse.
|
||||
*/
|
||||
@@ -794,6 +853,19 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch-safety check for puter-hosted `index_url`s. See
|
||||
* `util/hostedAppBacking.ts` — the check lives there because every producer
|
||||
* of launchable app metadata needs it, not just this driver.
|
||||
*/
|
||||
async #hostedIndexUrlBackingIsUnavailable(app) {
|
||||
return hostedIndexUrlBackingIsUnavailable({
|
||||
app,
|
||||
subdomainStore: this.stores.subdomain,
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
async #toClient(app, actor, params = {}) {
|
||||
if (!app) return null;
|
||||
|
||||
@@ -801,12 +873,14 @@ export class AppDriver extends PuterDriver {
|
||||
// batched query and threads them through `params.filetypes` to
|
||||
// avoid the N+1 in this hot loop. Single-app callers (`read`,
|
||||
// `create`, `update`) fall back to the per-app query.
|
||||
const [filetypes, canonicalForIndexUrl] = await Promise.all([
|
||||
params.filetypes !== undefined
|
||||
? Promise.resolve(params.filetypes)
|
||||
: this.appStore.getFiletypeAssociations(app.id),
|
||||
this.#resolveCanonicalForIndexUrl(app),
|
||||
]);
|
||||
const [filetypes, canonicalForIndexUrl, hostedBackingUnavailable] =
|
||||
await Promise.all([
|
||||
params.filetypes !== undefined
|
||||
? Promise.resolve(params.filetypes)
|
||||
: this.appStore.getFiletypeAssociations(app.id),
|
||||
this.#resolveCanonicalForIndexUrl(app),
|
||||
this.#hostedIndexUrlBackingIsUnavailable(app),
|
||||
]);
|
||||
|
||||
const createdFromOrigin =
|
||||
canonicalForIndexUrl && canonicalForIndexUrl.expectedUid === app.uid
|
||||
@@ -903,82 +977,24 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// Hosted-subdomain launch guard (independent of the private-app
|
||||
// gate): deny launch when the app's puter-hosted backing is gone or
|
||||
// has been reclaimed by another user, so the GUI never appends the
|
||||
// launch token to an origin the app owner no longer controls. Only
|
||||
// set when not already denied so a private app's existing decision
|
||||
// is preserved.
|
||||
if (
|
||||
hostedBackingUnavailable &&
|
||||
result.privateAccess?.hasAccess !== false
|
||||
) {
|
||||
result.privateAccess = buildHostedBackingDenial();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -- Puter-hosted index_url merge logic ---------------------------
|
||||
//
|
||||
// Ported from v1 AppES (`#maybeJoinOwnedHostedIndexUrlAppOnCreate`,
|
||||
// `#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`, …) — 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.
|
||||
// • If the same user already has an origin-bootstrap row at that
|
||||
// URL → merge into it; otherwise reject as a duplicate.
|
||||
// • If a different user owns the row → reject with
|
||||
// `app_index_url_already_in_use`.
|
||||
// Source uid (when called from `update`) is recorded in a kvstore
|
||||
// alias so `#resolve` can redirect old uids to the canonical row.
|
||||
|
||||
#normalizeConfiguredHostedDomain(domainValue) {
|
||||
if (typeof domainValue !== 'string') return null;
|
||||
const normalizedDomain = domainValue
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
if (!normalizedDomain) return null;
|
||||
return normalizedDomain.split(':')[0] || null;
|
||||
}
|
||||
|
||||
#getPuterHostedDomains() {
|
||||
const domains = new Set();
|
||||
const config = this.config ?? {};
|
||||
for (const configuredDomain of [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
]) {
|
||||
const normalized =
|
||||
this.#normalizeConfiguredHostedDomain(configuredDomain);
|
||||
if (normalized) domains.add(normalized);
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
|
||||
#extractPuterHostedSubdomain(indexUrl) {
|
||||
if (typeof indexUrl !== 'string' || !indexUrl) return null;
|
||||
|
||||
let hostname;
|
||||
try {
|
||||
hostname = new URL(indexUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort longest-first so `foo.puter.app` matches `puter.app` (not
|
||||
// a shorter `app` if it ever appeared in the configured list).
|
||||
const hostedDomains = this.#getPuterHostedDomains().sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
|
||||
for (const hostedDomain of hostedDomains) {
|
||||
const suffix = `.${hostedDomain}`;
|
||||
if (hostname.endsWith(suffix)) {
|
||||
const subdomain = hostname.slice(
|
||||
0,
|
||||
hostname.length - suffix.length,
|
||||
);
|
||||
return subdomain || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return extractPuterHostedSubdomain(indexUrl, this.config);
|
||||
}
|
||||
|
||||
#isPuterHostedIndexUrl(indexUrl) {
|
||||
@@ -1012,8 +1028,8 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the alias group containing this index_url's host, or null when
|
||||
* the host isn't claimed by any group.
|
||||
* Return the alias group containing this index_url's host, or null when the
|
||||
* host isn't claimed by any group.
|
||||
*/
|
||||
#findOriginAliasGroupForIndexUrl(indexUrl) {
|
||||
if (typeof indexUrl !== 'string' || !indexUrl) return null;
|
||||
@@ -1030,11 +1046,10 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the set of equivalent index_url strings that should
|
||||
* collide with a given input. We only collapse trailing-slash and
|
||||
* `/index.html` variants — the underlying `apps.index_url` column
|
||||
* is matched by exact string, so anything not in this list won't
|
||||
* be deduped. Mirrors v1 AppES exactly.
|
||||
* Generate the set of equivalent index_url strings that should collide with
|
||||
* a given input. We only collapse trailing-slash and `/index.html` variants
|
||||
* — the underlying `apps.index_url` column is matched by exact string, so
|
||||
* anything not in this list won't be deduped. Mirrors v1 AppES exactly.
|
||||
*/
|
||||
#buildEquivalentIndexUrlCandidates(indexUrl) {
|
||||
if (typeof indexUrl !== 'string' || !indexUrl.trim()) {
|
||||
@@ -1135,12 +1150,11 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin-bootstrap detection: rows auto-created when an unknown
|
||||
* origin first needed an app row (no human-supplied metadata).
|
||||
* Marker is `name === uid && title === uid` and a description
|
||||
* starting with "App created from origin ". Only these rows are
|
||||
* eligible for same-owner merging — refusing to merge arbitrary
|
||||
* same-owner apps prevents accidental data loss.
|
||||
* Origin-bootstrap detection: rows auto-created when an unknown origin
|
||||
* first needed an app row (no human-supplied metadata). Marker is `name ===
|
||||
* uid && title === uid` and a description starting with "App created from
|
||||
* origin ". Only these rows are eligible for same-owner merging — refusing
|
||||
* to merge arbitrary same-owner apps prevents accidental data loss.
|
||||
*/
|
||||
#isOriginBootstrapApp(app) {
|
||||
if (!app || typeof app !== 'object') return false;
|
||||
@@ -1228,16 +1242,15 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an incoming create/update into an existing app row that
|
||||
* 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).
|
||||
* Merge an incoming create/update into an existing app row that 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).
|
||||
*
|
||||
* `sourceAppUid` is set when called from update — when present and
|
||||
* different from the conflict row's uid, the source row is deleted
|
||||
* and an alias is recorded so old-uid clients keep resolving.
|
||||
* different from the conflict row's uid, the source row is deleted and an
|
||||
* alias is recorded so old-uid clients keep resolving.
|
||||
*/
|
||||
async #maybeJoinOwnedHostedIndexUrlApp({
|
||||
object,
|
||||
|
||||
@@ -918,6 +918,125 @@ describe('AppDriver.select additional branches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- select pagination --
|
||||
|
||||
describe('AppDriver.select pagination', () => {
|
||||
const makeApps = async (count: number) => {
|
||||
const { actor } = await makeUser();
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = uniqueName(`pg${i}`);
|
||||
names.push(name);
|
||||
await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: { name, title: 't', index_url: uniqueIndexUrl() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { actor, names };
|
||||
};
|
||||
|
||||
it('keeps the bare array response for plain limit requests', async () => {
|
||||
const { actor } = await makeApps(2);
|
||||
const result = await withActor(actor, () =>
|
||||
driver.select({ predicate: ['user-can-edit'], limit: 1 }),
|
||||
);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as unknown[]).length).toBe(1);
|
||||
});
|
||||
|
||||
it('pages through owned apps with cursors', async () => {
|
||||
const { actor, names } = await makeApps(5);
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({
|
||||
predicate: ['user-can-edit'],
|
||||
limit: 2,
|
||||
cursor,
|
||||
}),
|
||||
)) as { items: Array<{ name: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen).toEqual(names);
|
||||
});
|
||||
|
||||
it('supports offset paging', async () => {
|
||||
const { actor, names } = await makeApps(3);
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({
|
||||
predicate: ['user-can-edit'],
|
||||
limit: 10,
|
||||
offset: 1,
|
||||
}),
|
||||
)) as { items: Array<{ name: string }> };
|
||||
expect(page.items.map((r) => r.name)).toEqual(names.slice(1));
|
||||
});
|
||||
|
||||
it('rejects cursor combined with offset', async () => {
|
||||
const { actor } = await makeApps(2);
|
||||
const first = (await withActor(actor, () =>
|
||||
driver.select({
|
||||
predicate: ['user-can-edit'],
|
||||
limit: 1,
|
||||
cursor: null,
|
||||
}),
|
||||
)) as { cursor?: string };
|
||||
expect(first.cursor).toBeDefined();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.select({ offset: 1, cursor: first.cursor }),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('reports an exact total for owner-scoped selects', async () => {
|
||||
const { actor, names } = await makeApps(3);
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({
|
||||
predicate: ['user-can-edit'],
|
||||
limit: 1,
|
||||
includeTotal: true,
|
||||
}),
|
||||
)) as { items: unknown[]; total?: number };
|
||||
expect(page.items.length).toBe(1);
|
||||
expect(page.total).toBe(names.length);
|
||||
});
|
||||
|
||||
it("hides other users' protected apps from paginated catalog listings", async () => {
|
||||
const a = await makeApps(3);
|
||||
const [visible1, hidden, visible2] = a.names;
|
||||
const created = await withActor(a.actor, () =>
|
||||
driver.read({ id: { name: hidden } }),
|
||||
);
|
||||
const row = await server.stores.app.getByUid(
|
||||
(created as Record<string, unknown>).uid as string,
|
||||
);
|
||||
await server.clients.db.write(
|
||||
'UPDATE `apps` SET `protected` = 1 WHERE `id` = ?',
|
||||
[row!.id],
|
||||
);
|
||||
await server.stores.app.invalidateByUid(row!.uid as string);
|
||||
|
||||
const b = await makeUser();
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(b.actor, () =>
|
||||
driver.select({ limit: 50, cursor }),
|
||||
)) as { items: Array<{ name: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
|
||||
expect(seen).toContain(visible1);
|
||||
expect(seen).toContain(visible2);
|
||||
expect(seen).not.toContain(hidden);
|
||||
});
|
||||
});
|
||||
|
||||
// ── upsert ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('AppDriver.upsert additional branches', () => {
|
||||
@@ -1139,6 +1258,37 @@ describe('AppDriver hosted-subdomain ownership check', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('create merges into an owner-stamped bootstrap stub for an owned subdomain', async () => {
|
||||
// The get-user-app-token bootstrap path stamps the subdomain owner
|
||||
// on the stub at mint time — the owner's later create must still
|
||||
// absorb the stub (claimOwnership is skipped, merge proceeds).
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = uniqueName('ownedstub');
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
const stubUid = `app-${uuidv4()}`;
|
||||
await server.stores.app.createFromOrigin(
|
||||
stubUid,
|
||||
`https://${sub}.site.puter.localhost`,
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
|
||||
const name = uniqueName('owned-create');
|
||||
const result = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name,
|
||||
title: 'Owned stub',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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 a hosted index_url whose subdomain does not exist anywhere', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
@@ -1175,4 +1325,116 @@ describe('AppDriver hosted-subdomain ownership check', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// -- Launch guard: the backing subdomain can disappear AFTER the app
|
||||
// was created (deleted by its owner, then reclaimable by anyone). The
|
||||
// read path must refuse to launch so the GUI never appends the launch
|
||||
// token to a now-reclaimable origin.
|
||||
|
||||
it('denies launch when the hosted subdomain is later deleted', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = uniqueName('gone');
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'Backed App',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// While the subdomain is still owned, the app launches normally.
|
||||
const before = await withActor(actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
expect(String(before.index_url)).toContain(sub);
|
||||
expect(
|
||||
(before.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
?.hasAccess,
|
||||
).not.toBe(false);
|
||||
|
||||
// Delete the subdomain but keep the app pointing at it.
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId },
|
||||
);
|
||||
|
||||
const after = await withActor(actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
const access = after.privateAccess as {
|
||||
hasAccess?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
expect(access?.hasAccess).toBe(false);
|
||||
expect(access?.reason).toBe('hosted_backing_unavailable');
|
||||
});
|
||||
|
||||
it('denies launch when the hosted subdomain was reclaimed by another user', async () => {
|
||||
const owner = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const sub = uniqueName('reclaim');
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const created = await withActor(owner.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'Backed App',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Owner deletes the subdomain; the attacker re-registers the name.
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
await server.stores.subdomain.create({
|
||||
userId: attacker.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const after = await withActor(owner.actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
expect(
|
||||
(after.privateAccess as { hasAccess?: boolean }).hasAccess,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps launching while the hosted subdomain is still owned', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sub = uniqueName('live');
|
||||
await server.stores.subdomain.create({ userId, subdomain: sub });
|
||||
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'Backed App',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await withActor(actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
expect(String(result.index_url)).toContain(sub);
|
||||
expect(
|
||||
(result.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
?.hasAccess,
|
||||
).not.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 { describe, expect, it } from 'vitest';
|
||||
import { puterDrivers } from './index.js';
|
||||
import { RESERVED_DRIVER_METHODS, resolveCallableMethods } from './meta.js';
|
||||
|
||||
// Guard on the exact set of methods each driver exposes over `/drivers/call`.
|
||||
// The RPC surface is derived structurally (see `resolveCallableMethods`): a
|
||||
// method is callable iff it is a novel public method on the concrete driver
|
||||
// class. This test pins that surface so that ADDING a plain public method to a
|
||||
// driver — which would silently make it a remote endpoint — fails CI until the
|
||||
// expected list here is updated. It is the "fail loud" backstop for the
|
||||
// otherwise fail-open structural gate.
|
||||
//
|
||||
// PuterDriver's constructor signature is (config, clients, stores, services);
|
||||
// field initializers don't read them, so empty mocks are fine (same trick as
|
||||
// driverPolicies.test.ts).
|
||||
const fake = () => [{}, {}, {}, {}] as [any, any, any, any];
|
||||
|
||||
// Expected callable surface, keyed by the registry key in `puterDrivers`.
|
||||
// Keep alphabetical within each list for easy diffing.
|
||||
const EXPECTED: Record<string, string[]> = {
|
||||
kvStore: [
|
||||
'add', 'batchPut', 'decr', 'del', 'expire', 'expireAt', 'flush',
|
||||
'get', 'incr', 'list', 'remove', 'set', 'update',
|
||||
],
|
||||
aiChat: ['complete', 'list', 'models'],
|
||||
aiImage: ['generate', 'list', 'models'],
|
||||
aiTts: ['list', 'list_engines', 'list_voices', 'synthesize'],
|
||||
aiVideo: ['generate', 'list', 'models'],
|
||||
aiSpeech2Speech: ['convert'],
|
||||
aiSpeech2Txt: ['list', 'list_models', 'transcribe', 'translate'],
|
||||
aiOcr: ['recognize'],
|
||||
// AppDriver is the legacy `.js` driver: its non-RPC helpers are plain
|
||||
// public methods (not `#`-private), so `isNameAvailable` (an AppController
|
||||
// helper) sits on the callable surface. It is (and always was, under the
|
||||
// old reflection dispatch) remotely callable — pinned here rather than
|
||||
// silently exposed. See the follow-up note: lock it down by making it
|
||||
// `#`-private with a dedicated call site if the exposure is unwanted.
|
||||
apps: [
|
||||
'create', 'delete', 'isNameAvailable', 'read', 'select',
|
||||
'update', 'upsert',
|
||||
],
|
||||
subdomains: ['create', 'delete', 'read', 'select', 'update', 'upsert'],
|
||||
notifications: ['create', 'mark_acknowledged', 'mark_shown', 'read', 'select'],
|
||||
workers: ['create', 'destroy', 'getFilePaths', 'getLoggingUrl'],
|
||||
};
|
||||
|
||||
describe('driver callable-method surface', () => {
|
||||
for (const [key, DriverClass] of Object.entries(puterDrivers)) {
|
||||
const instance = new (DriverClass as new (
|
||||
...a: [any, any, any, any]
|
||||
) => object)(...fake());
|
||||
const callable = resolveCallableMethods(instance);
|
||||
|
||||
it(`${key}: exposes exactly its declared RPC methods`, () => {
|
||||
expect([...callable].sort()).toEqual(EXPECTED[key]);
|
||||
});
|
||||
|
||||
it(`${key}: never exposes lifecycle/framework methods`, () => {
|
||||
for (const reserved of RESERVED_DRIVER_METHODS) {
|
||||
expect(callable.has(reserved)).toBe(false);
|
||||
}
|
||||
for (const framework of [
|
||||
'constructor',
|
||||
'toString',
|
||||
'valueOf',
|
||||
'hasOwnProperty',
|
||||
'isPrototypeOf',
|
||||
]) {
|
||||
expect(callable.has(framework)).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DRIVER_DEFAULT_KEY,
|
||||
DRIVER_INTERFACE_KEY,
|
||||
DRIVER_NAME_KEY,
|
||||
DRIVER_NO_USER_SESSION_KEY,
|
||||
DRIVER_RATE_LIMIT_KEY,
|
||||
validateDriverConcurrent,
|
||||
validateDriverRateLimit,
|
||||
@@ -80,6 +81,12 @@ export interface DriverOptions {
|
||||
* unbounded — matching today's behaviour where nothing is gated.
|
||||
*/
|
||||
concurrent?: DriverConcurrentConfig;
|
||||
/**
|
||||
* When true, `/drivers/call` rejects bare account-session ("root")
|
||||
* tokens for this driver — callers need an app/worker token or a
|
||||
* dashboard-minted API token. See `DriverMeta.noUserSession`.
|
||||
*/
|
||||
noUserSession?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -130,5 +137,6 @@ export function Driver(interfaceName: string, opts: DriverOptions = {}) {
|
||||
proto[DRIVER_DEFAULT_KEY] = opts.default ?? false;
|
||||
if (rateLimit) proto[DRIVER_RATE_LIMIT_KEY] = rateLimit;
|
||||
if (concurrent) proto[DRIVER_CONCURRENT_KEY] = concurrent;
|
||||
if (opts.noUserSession) proto[DRIVER_NO_USER_SESSION_KEY] = true;
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import { ImageGenerationDriver } from './ai-image/ImageGenerationDriver.js';
|
||||
import { OCRDriver } from './ai-ocr/OCRDriver.js';
|
||||
import { VoiceChangerDriver } from './ai-speech2speech/VoiceChangerDriver.js';
|
||||
import { SpeechToTextDriver } from './ai-speech2txt/SpeechToTextDriver.js';
|
||||
import { XAISpeechToTextDriver } from './ai-speech2txt/XAISpeechToTextDriver.js';
|
||||
import { TTSDriver } from './ai-tts/TTSDriver.js';
|
||||
import { VideoGenerationDriver } from './ai-video/VideoGenerationDriver.js';
|
||||
import { AppDriver } from './apps/AppDriver.js';
|
||||
@@ -136,7 +135,6 @@ describe.each([
|
||||
['TTSDriver', () => new TTSDriver(...fake())],
|
||||
['VoiceChangerDriver', () => new VoiceChangerDriver(...fake())],
|
||||
['SpeechToTextDriver', () => new SpeechToTextDriver(...fake())],
|
||||
['XAISpeechToTextDriver', () => new XAISpeechToTextDriver(...fake())],
|
||||
['OCRDriver', () => new OCRDriver(...fake())],
|
||||
])('AI driver — %s', (_name, build) => {
|
||||
const m = meta(build());
|
||||
@@ -150,21 +148,38 @@ describe.each([
|
||||
it('points at the shared AI_CONCURRENT constant', () => {
|
||||
expect(m.concurrent).toBe(AI_CONCURRENT);
|
||||
});
|
||||
|
||||
it('accepts bare account-session ("root") tokens', () => {
|
||||
// Privileged ("godmode") apps run on the user's own session token
|
||||
// rather than an app token, so the AI drivers can't distinguish
|
||||
// them from a browser session and have to admit both.
|
||||
expect(m.noUserSession).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-AI drivers — session tokens stay allowed', () => {
|
||||
it.each([
|
||||
['KVStoreDriver', () => new KVStoreDriver(...fake())],
|
||||
['AppDriver', () => new AppDriver(...fake())],
|
||||
['SubdomainDriver', () => new SubdomainDriver(...fake())],
|
||||
['NotificationDriver', () => new NotificationDriver(...fake())],
|
||||
])('%s does not set noUserSession', (_name, build) => {
|
||||
expect(meta(build()).noUserSession).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Iface coordination cross-check ──────────────────────────────────
|
||||
|
||||
describe('puter-speech2txt — sibling drivers share the bucket', () => {
|
||||
// Both speech2txt impls register on the same interface. The
|
||||
// controller keys rate/concurrent by (iface, method, user) — so as
|
||||
// long as they hold the same policy reference, switching providers
|
||||
// mid-session can't dodge the cap.
|
||||
it('SpeechToText and XAISpeechToText carry identical policies', () => {
|
||||
const a = meta(new SpeechToTextDriver(...fake()));
|
||||
const b = meta(new XAISpeechToTextDriver(...fake()));
|
||||
expect(a.interfaceName).toBe('puter-speech2txt');
|
||||
expect(b.interfaceName).toBe('puter-speech2txt');
|
||||
expect(a.rateLimit).toBe(b.rateLimit);
|
||||
expect(a.concurrent).toBe(b.concurrent);
|
||||
describe('puter-speech2txt — one driver covers every provider', () => {
|
||||
// A single driver serves the interface, so the controller's
|
||||
// (iface, method, user) bucket already spans every provider and
|
||||
// switching providers mid-session can't dodge the cap.
|
||||
it('answers to the legacy per-provider driver names', () => {
|
||||
const m = meta(new SpeechToTextDriver(...fake()));
|
||||
expect(m.interfaceName).toBe('puter-speech2txt');
|
||||
expect(m.driverName).toBe('ai-speech2txt');
|
||||
expect(m.aliases).toEqual(
|
||||
expect.arrayContaining(['openai-speech2txt', 'xai-speech2txt']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import { ChatCompletionDriver } from './ai-chat/ChatCompletionDriver';
|
||||
@@ -22,7 +23,6 @@ import { ImageGenerationDriver } from './ai-image/ImageGenerationDriver';
|
||||
import { OCRDriver } from './ai-ocr/OCRDriver';
|
||||
import { VoiceChangerDriver } from './ai-speech2speech/VoiceChangerDriver';
|
||||
import { SpeechToTextDriver } from './ai-speech2txt/SpeechToTextDriver';
|
||||
import { XAISpeechToTextDriver } from './ai-speech2txt/XAISpeechToTextDriver';
|
||||
import { TTSDriver } from './ai-tts/TTSDriver';
|
||||
import { VideoGenerationDriver } from './ai-video/VideoGenerationDriver';
|
||||
import { AppDriver } from './apps/AppDriver.js';
|
||||
@@ -43,7 +43,6 @@ export const puterDrivers = {
|
||||
aiVideo: VideoGenerationDriver,
|
||||
aiSpeech2Speech: VoiceChangerDriver,
|
||||
aiSpeech2Txt: SpeechToTextDriver,
|
||||
aiSpeech2TxtXai: XAISpeechToTextDriver,
|
||||
aiOcr: OCRDriver,
|
||||
apps: AppDriver,
|
||||
subdomains: SubdomainDriver,
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('KVStoreDriver', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// Each test runs against a unique actor namespace so state from one test
|
||||
|
||||
@@ -223,6 +223,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
limit?: number;
|
||||
cursor?: string | Record<string, unknown>;
|
||||
pattern?: string;
|
||||
offset?: number;
|
||||
includeTotal?: boolean;
|
||||
fetchUntilFull?: boolean;
|
||||
optConfig?: { appUuid?: string };
|
||||
}): Promise<unknown> {
|
||||
const opts = this.#opts(args.optConfig?.appUuid);
|
||||
@@ -232,6 +235,9 @@ export class KVStoreDriver extends PuterDriver {
|
||||
limit: args.limit,
|
||||
cursor: args.cursor,
|
||||
pattern: args.pattern,
|
||||
offset: args.offset,
|
||||
includeTotal: args.includeTotal,
|
||||
fetchUntilFull: args.fetchUntilFull,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
+120
-43
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* 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/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Readable } from 'node:stream';
|
||||
@@ -57,6 +58,7 @@ export const DRIVER_DEFAULT_KEY = '__driverDefault' as const;
|
||||
export const DRIVER_ALIASES_KEY = '__driverAliases' as const;
|
||||
export const DRIVER_RATE_LIMIT_KEY = '__driverRateLimit' as const;
|
||||
export const DRIVER_CONCURRENT_KEY = '__driverConcurrent' as const;
|
||||
export const DRIVER_NO_USER_SESSION_KEY = '__driverNoUserSession' as const;
|
||||
|
||||
// -- Driver rate-limit config ----------------------------------------
|
||||
//
|
||||
@@ -77,24 +79,24 @@ export interface DriverRateLimitSpec {
|
||||
/** Window length, in milliseconds. */
|
||||
window: number;
|
||||
/**
|
||||
* Per-subscription overrides for `limit`. Keyed by
|
||||
* `SubscriptionPolicy.id` (`user_free`, `temp_free`, `unlimited`,
|
||||
* etc.). Falls back to `limit` when the actor's subscription isn't
|
||||
* in the map. Same mechanic as `DriverConcurrentSpec.bySubscription`.
|
||||
* Per-subscription overrides for `limit`. Keyed by `SubscriptionPolicy.id`
|
||||
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to `limit` when
|
||||
* the actor's subscription isn't in the map. Same mechanic as
|
||||
* `DriverConcurrentSpec.bySubscription`.
|
||||
*/
|
||||
bySubscription?: Record<string, number>;
|
||||
/**
|
||||
* Storage backend to count against. Omit to use the server-wide
|
||||
* default configured by `config.rate_limit.backend`.
|
||||
* Storage backend to count against. Omit to use the server-wide default
|
||||
* configured by `config.rate_limit.backend`.
|
||||
*/
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
export interface DriverRateLimitConfig {
|
||||
/**
|
||||
* Applied to any method not listed in `methods`. Lets a driver opt
|
||||
* the whole interface into tighter limits than the global driver
|
||||
* default without enumerating every method.
|
||||
* Applied to any method not listed in `methods`. Lets a driver opt the
|
||||
* whole interface into tighter limits than the global driver default
|
||||
* without enumerating every method.
|
||||
*/
|
||||
default?: DriverRateLimitSpec;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
@@ -102,10 +104,9 @@ export interface DriverRateLimitConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `rateLimit` block declared by a driver. Throws on bad
|
||||
* shape so registration fails loudly at boot rather than silently
|
||||
* misconfiguring production traffic. Returns the value unchanged on
|
||||
* success for chaining.
|
||||
* Validate a `rateLimit` block declared by a driver. Throws on bad shape so
|
||||
* registration fails loudly at boot rather than silently misconfiguring
|
||||
* production traffic. Returns the value unchanged on success for chaining.
|
||||
*/
|
||||
export function validateDriverRateLimit(
|
||||
value: unknown,
|
||||
@@ -182,9 +183,9 @@ function validateBySubscription(value: unknown, label: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the spec that applies to a given method on a driver. Per-method
|
||||
* entry wins over `default`; returns `undefined` if neither is set so the
|
||||
* caller can apply its own fallback.
|
||||
* Resolve the spec that applies to a given method on a driver. Per-method entry
|
||||
* wins over `default`; returns `undefined` if neither is set so the caller can
|
||||
* apply its own fallback.
|
||||
*/
|
||||
export function resolveDriverMethodRateLimit(
|
||||
cfg: DriverRateLimitConfig | undefined,
|
||||
@@ -204,15 +205,15 @@ export interface DriverConcurrentSpec {
|
||||
/** Maximum simultaneous in-flight requests. */
|
||||
limit: number;
|
||||
/**
|
||||
* Per-subscription overrides keyed by `SubscriptionPolicy.id`
|
||||
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to
|
||||
* `limit` when the actor's subscription isn't in the map.
|
||||
* Per-subscription overrides keyed by `SubscriptionPolicy.id` (`user_free`,
|
||||
* `temp_free`, `unlimited`, etc.). Falls back to `limit` when the actor's
|
||||
* subscription isn't in the map.
|
||||
*/
|
||||
bySubscription?: Record<string, number>;
|
||||
/**
|
||||
* Storage backend. Memory is per-process (use only on single-node
|
||||
* deployments); redis coordinates across nodes; kv is rarely the
|
||||
* right choice for concurrency but supported for parity.
|
||||
* deployments); redis coordinates across nodes; kv is rarely the right
|
||||
* choice for concurrency but supported for parity.
|
||||
*/
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
@@ -225,8 +226,8 @@ export interface DriverConcurrentConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `concurrent` block. Mirrors `validateDriverRateLimit` —
|
||||
* throws with a labelled path so a malformed entry surfaces at boot.
|
||||
* Validate a `concurrent` block. Mirrors `validateDriverRateLimit` — throws
|
||||
* with a labelled path so a malformed entry surfaces at boot.
|
||||
*/
|
||||
export function validateDriverConcurrent(
|
||||
value: unknown,
|
||||
@@ -283,10 +284,10 @@ function validateConcurrentSpec(value: unknown, label: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the concurrent spec for a given method on a driver. Same
|
||||
* precedence as `resolveDriverMethodRateLimit`: per-method wins over
|
||||
* `default`; `undefined` means no concurrency cap is declared, and the
|
||||
* caller should leave the method unbounded.
|
||||
* Resolve the concurrent spec for a given method on a driver. Same precedence
|
||||
* as `resolveDriverMethodRateLimit`: per-method wins over `default`;
|
||||
* `undefined` means no concurrency cap is declared, and the caller should leave
|
||||
* the method unbounded.
|
||||
*/
|
||||
export function resolveDriverMethodConcurrent(
|
||||
cfg: DriverConcurrentConfig | undefined,
|
||||
@@ -319,22 +320,30 @@ export interface DriverMeta {
|
||||
/**
|
||||
* Rate-limit policy for this driver. Per-method specs override the
|
||||
* `default` spec; both are optional. `DriverController` consults this
|
||||
* before invoking the method and falls back to the global driver
|
||||
* default (600/min) if nothing is declared.
|
||||
* before invoking the method and falls back to the global driver default
|
||||
* (600/min) if nothing is declared.
|
||||
*/
|
||||
rateLimit?: DriverRateLimitConfig;
|
||||
/**
|
||||
* Concurrent in-flight policy for this driver. When set, the
|
||||
* controller acquires a slot before invoking the method and releases
|
||||
* in `finally`. Absent → no concurrency cap (current behaviour).
|
||||
* Concurrent in-flight policy for this driver. When set, the controller
|
||||
* acquires a slot before invoking the method and releases in `finally`.
|
||||
* Absent → no concurrency cap (current behaviour).
|
||||
*/
|
||||
concurrent?: DriverConcurrentConfig;
|
||||
/**
|
||||
* When true, `/drivers/call` rejects bare account-session ("root") tokens
|
||||
* for this driver: callers must present an app/worker token or a
|
||||
* dashboard-minted API token. Per-driver counterpart of the `noUserSession`
|
||||
* route option — the dispatch route is shared, so the flag lives on the
|
||||
* driver.
|
||||
*/
|
||||
noUserSession?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract driver metadata from a driver instance. Checks decorator-set
|
||||
* prototype metadata first, then falls back to instance properties.
|
||||
* Returns `null` if the driver doesn't declare an interface.
|
||||
* prototype metadata first, then falls back to instance properties. Returns
|
||||
* `null` if the driver doesn't declare an interface.
|
||||
*/
|
||||
export function resolveDriverMeta(
|
||||
driver: WithLifecycle & Record<string, unknown>,
|
||||
@@ -384,6 +393,11 @@ export function resolveDriverMeta(
|
||||
);
|
||||
}
|
||||
|
||||
const noUserSession =
|
||||
(proto[DRIVER_NO_USER_SESSION_KEY] as boolean | undefined) ??
|
||||
(driver.noUserSession as boolean | undefined) ??
|
||||
false;
|
||||
|
||||
if (!interfaceName || !driverName) return null;
|
||||
|
||||
return {
|
||||
@@ -393,5 +407,68 @@ export function resolveDriverMeta(
|
||||
aliases,
|
||||
rateLimit,
|
||||
concurrent,
|
||||
noUserSession,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework/lifecycle method names that must never be reachable via
|
||||
* `/drivers/call`. These live on `PuterDriver` (see `drivers/types.ts`) and are
|
||||
* the machinery the dispatch surface must exclude. For class-based drivers a
|
||||
* concrete `override` of one still carries the same name and is caught here;
|
||||
* for plain-object drivers (registered by extensions — see `server.ts`, `typeof
|
||||
* DriverClass === 'object'`) there is no base prototype to distinguish them, so
|
||||
* this denylist is the _only_ thing keeping a hook off the RPC surface. Any
|
||||
* lifecycle hook added to `PuterDriver` must be added here too — the per-driver
|
||||
* guard test (`callableMethods.test.ts`) fails loudly if a base method starts
|
||||
* leaking into every driver's surface.
|
||||
*/
|
||||
export const RESERVED_DRIVER_METHODS: ReadonlySet<string> = new Set([
|
||||
'onServerStart',
|
||||
'onServerPrepareShutdown',
|
||||
'onServerShutdown',
|
||||
'getReportedCosts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Compute the set of method names a driver exposes over `/drivers/call`.
|
||||
*
|
||||
* The RPC surface is defined structurally rather than by a hand-maintained
|
||||
* per-method allow-list. Walking from the instance up to (but not including)
|
||||
* `Object.prototype`, a name is callable iff it resolves to a function and is
|
||||
* neither `constructor` nor a `RESERVED_DRIVER_METHODS` entry. This covers both
|
||||
* driver shapes the server accepts (`server.ts`): class instances (RPC methods
|
||||
* on the concrete prototype, config on the instance) and plain objects
|
||||
* (everything own, used verbatim by extensions). It excludes all
|
||||
* `Object.prototype` members (`toString`, `valueOf`, `__proto__`, …), the
|
||||
* `constructor`, and the lifecycle hooks.
|
||||
*
|
||||
* `#`-private helpers need no handling: they are not real property keys, so
|
||||
* `getOwnPropertyNames` never lists them and `driver['#x']` is `undefined`.
|
||||
* Only _plain_ public methods can appear here.
|
||||
*
|
||||
* Getters are excluded (we read the descriptor's `.value`, never access the
|
||||
* property), so evaluating this set never runs driver code. Intended to be
|
||||
* called once per driver at registration and cached — not on the hot path.
|
||||
*/
|
||||
export function resolveCallableMethods(driver: object): Set<string> {
|
||||
const callable = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
for (
|
||||
let o: object | null = driver;
|
||||
o && o !== Object.prototype;
|
||||
o = Object.getPrototypeOf(o) as object | null
|
||||
) {
|
||||
for (const name of Object.getOwnPropertyNames(o)) {
|
||||
// First (lowest) definition wins — a subclass override shadows
|
||||
// the base, and we decide against the resolved descriptor.
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
if (name === 'constructor') continue;
|
||||
if (RESERVED_DRIVER_METHODS.has(name)) continue;
|
||||
const desc = Object.getOwnPropertyDescriptor(o, name);
|
||||
if (desc && typeof desc.value === 'function') callable.add(name);
|
||||
}
|
||||
}
|
||||
return callable;
|
||||
}
|
||||
@@ -332,6 +332,103 @@ describe('SubdomainDriver.read / select', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- select pagination --
|
||||
|
||||
describe('SubdomainDriver.select pagination', () => {
|
||||
const makeSubs = async (count: number) => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const subs: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sub = uniqueSubdomain(`page${i}`);
|
||||
subs.push(sub);
|
||||
await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: { subdomain: sub, root_dir: `/${username}/Public` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { actor, subs };
|
||||
};
|
||||
|
||||
it('keeps the bare array response for plain limit requests', async () => {
|
||||
const { actor } = await makeSubs(2);
|
||||
const result = await withActor(actor, () =>
|
||||
driver.select({ limit: 10 }),
|
||||
);
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('pages through subdomains with cursors', async () => {
|
||||
const { actor, subs } = await makeSubs(3);
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 2, cursor }),
|
||||
)) as { items: Array<{ subdomain: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((r) => r.subdomain));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
expect(seen.sort()).toEqual([...subs].sort());
|
||||
});
|
||||
|
||||
it('supports offset paging', async () => {
|
||||
const { actor, subs } = await makeSubs(3);
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 10, offset: 1 }),
|
||||
)) as { items: Array<{ subdomain: string }> };
|
||||
expect(page.items.length).toBe(subs.length - 1);
|
||||
});
|
||||
|
||||
it('rejects cursor combined with offset', async () => {
|
||||
const { actor } = await makeSubs(2);
|
||||
const first = (await withActor(actor, () =>
|
||||
driver.select({ limit: 1, cursor: null }),
|
||||
)) as { cursor?: string };
|
||||
expect(first.cursor).toBeDefined();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.select({ offset: 1, cursor: first.cursor }),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('excludes worker-backed subdomains from listings and totals', async () => {
|
||||
const { actor, subs } = await makeSubs(2);
|
||||
await server.stores.subdomain.create({
|
||||
userId: actor.user!.id as number,
|
||||
subdomain: `workers.puter.wk-${Date.now()}`,
|
||||
rootDirId: null,
|
||||
associatedAppId: null,
|
||||
appOwner: null,
|
||||
});
|
||||
|
||||
const bare = (await withActor(actor, () =>
|
||||
driver.select({}),
|
||||
)) as Array<{ subdomain: string }>;
|
||||
expect(bare.map((r) => r.subdomain).sort()).toEqual([...subs].sort());
|
||||
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 10, cursor: null, includeTotal: true }),
|
||||
)) as { items: Array<{ subdomain: string }>; total?: number };
|
||||
expect(page.items.map((r) => r.subdomain).sort()).toEqual(
|
||||
[...subs].sort(),
|
||||
);
|
||||
expect(page.total).toBe(subs.length);
|
||||
});
|
||||
|
||||
it('reports total scoped to the actor with includeTotal', async () => {
|
||||
const { actor, subs } = await makeSubs(3);
|
||||
await makeSubs(2); // another user's rows must not count
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 1, includeTotal: true }),
|
||||
)) as { items: unknown[]; total?: number };
|
||||
expect(page.items.length).toBe(1);
|
||||
expect(page.total).toBe(subs.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ── update ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('SubdomainDriver.update', () => {
|
||||
|
||||
@@ -32,6 +32,13 @@ import type { DriverRateLimitConfig } from '../meta.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
normalizeLimit,
|
||||
normalizeOffset,
|
||||
} from '../../util/pagination.js';
|
||||
|
||||
const SUBDOMAIN_MAX_LEN = 64;
|
||||
const SUBDOMAIN_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
||||
@@ -171,12 +178,24 @@ export class SubdomainDriver extends PuterDriver {
|
||||
return shaped ?? null;
|
||||
}
|
||||
|
||||
async select(args: Record<string, unknown>): Promise<unknown[]> {
|
||||
async select(args: Record<string, unknown>): Promise<unknown> {
|
||||
const actor = this.#requireActor();
|
||||
this.#requireUser(actor);
|
||||
|
||||
const predicate = args.predicate as unknown[] | string | undefined;
|
||||
const limit = Math.min(Number(args.limit ?? 5000), 5000);
|
||||
const limit = normalizeLimit(args.limit, { cap: 5000 }) ?? 5000;
|
||||
const offset = normalizeOffset(args.offset);
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(args, 'cursor');
|
||||
const payload = decodeCursor(
|
||||
args.cursor as string | null | undefined,
|
||||
) as { id?: number } | undefined;
|
||||
if (payload && offset !== undefined) {
|
||||
throw new HttpError(400, 'cursor and offset cannot be combined', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const includeTotal = args.includeTotal === true;
|
||||
const paginated = hasCursor || offset !== undefined || includeTotal;
|
||||
|
||||
// Match v1: when the actor has `read-all-subdomains` (admin /
|
||||
// privileged accounts), widen to every subdomain. Without this
|
||||
@@ -186,19 +205,55 @@ export class SubdomainDriver extends PuterDriver {
|
||||
predicate !== 'user-can-edit' &&
|
||||
(await this.#hasPermission(actor, 'read-all-subdomains'));
|
||||
|
||||
let rows = widenToAll
|
||||
? await this.stores.subdomain.listAll({ limit })
|
||||
: await this.stores.subdomain.listByUserId(actor.user.id, {
|
||||
limit,
|
||||
});
|
||||
// App actors only see subdomains they own; read-all bypasses scoping.
|
||||
const appOwner = !widenToAll && actor.app ? actor.app.id : undefined;
|
||||
// Worker deployments live in the same table but aren't sites —
|
||||
// they're listed through the workers driver instead.
|
||||
const listOpts = {
|
||||
limit: paginated ? limit + 1 : limit,
|
||||
offset,
|
||||
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
|
||||
appOwner,
|
||||
excludePrefix: WORKER_SUBDOMAIN_PREFIX,
|
||||
};
|
||||
|
||||
// App-limited: app actors only see subdomains they own. Skip when
|
||||
// we widened above — read-all is meant to bypass scoping.
|
||||
if (!widenToAll && actor.app) {
|
||||
rows = rows.filter((r) => r.app_owner === actor.app!.id);
|
||||
let rows = (
|
||||
widenToAll
|
||||
? await this.stores.subdomain.listAll(listOpts)
|
||||
: await this.stores.subdomain.listByUserId(
|
||||
actor.user.id,
|
||||
listOpts,
|
||||
)
|
||||
) as Array<Record<string, unknown>>;
|
||||
|
||||
let cursor: string | undefined;
|
||||
if (paginated && rows.length > limit) {
|
||||
rows = rows.slice(0, limit);
|
||||
const last = rows[rows.length - 1]!;
|
||||
cursor = encodeCursor({ id: Number(last.id) });
|
||||
}
|
||||
|
||||
return this.#hydrateRows(rows as Array<Record<string, unknown>>);
|
||||
const items = await this.#hydrateRows(rows);
|
||||
if (!paginated) return items;
|
||||
|
||||
let total: number | undefined;
|
||||
if (includeTotal) {
|
||||
total = widenToAll
|
||||
? await this.stores.subdomain.count({
|
||||
excludePrefix: WORKER_SUBDOMAIN_PREFIX,
|
||||
})
|
||||
: await this.stores.subdomain.count({
|
||||
userId: actor.user.id,
|
||||
appOwner,
|
||||
excludePrefix: WORKER_SUBDOMAIN_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
...(cursor ? { cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async update(args: Record<string, unknown>): Promise<unknown> {
|
||||
|
||||
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user