Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 6050ff78c3 Match dashboard paths in any letter case, and stop the e2e tests waiting on .desktop
- bootsDashboard() matches /dashboard and /app/<name> case-insensitively, as the server routes them, so /App/<name> boots the dashboard.
- The request-permission hardening tests wait for the page's own answer instead of the removed .desktop element.
- Remove the error_user_or_path_not_found string from fr.js.
2026-11-12 17:31:36 -07:00
Nariman Jelveh abb647d227 Remove the error_user_or_path_not_found string
Only the desktop's shared-item opener showed it.
2026-11-12 16:53:33 -07:00
Nariman Jelveh fa2d2d226a Remove UIDesktop
Nothing reaches it except a signed-in top-level /action/* page, where it
rendered an empty surface behind the page's dialog. Those pages now show
only the dialog, without a socket connection or notification toasts.

The full-page window styling it defined moves into UIWindow, its only
caller. The desktop-only closeLocalBanner helper, ProcessService's
gui:ready hook, and the .desktop and .window-container styles go with it.
2026-11-12 16:48:36 -07:00
Nariman Jelveh bb55d6c6a4 Boot the dashboard for framed / and /app/<name>, and remove the desktop's URL app launch
The root path and app landings boot the dashboard whether or not the page
is framed, so the desktop no longer launches an app from the URL. With that
gone, the fullpage_on_landing lookups, ?puter.fullpage= and the Dev Center
"full-page mode" checkbox have nothing to act on and are removed.
2026-11-12 16:40:04 -07:00
576 changed files with 6053 additions and 22672 deletions

No files matched your search

+5 -8
View File
@@ -2,7 +2,7 @@
# The last matching rule takes precedence.
* @Salazareo
- @Salazareo
# GUI
@@ -18,10 +18,7 @@
# Backend AI
/src/backend/controllers/puterai/ @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai/ @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/util/aiCostFactor* @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/util/aiPrices* @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/util/aiProviderKey* @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai-\*/ @404oops @ProgrammerIn-wonderland @Salazareo
# Backend: AI, hosting, workers, networking, and WebDAV
@@ -32,7 +29,7 @@
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox\* @ProgrammerIn-wonderland @Salazareo
/src/worker/ @ProgrammerIn-wonderland @Salazareo
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
@@ -45,7 +42,7 @@
/src/backend/services/permission/ @jfcastro92 @Salazareo
/src/backend/services/acl/ @jfcastro92 @Salazareo
/src/backend/services/team/ @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService\* @jfcastro92 @Salazareo
/src/backend/stores/share/ @jfcastro92 @Salazareo
/src/backend/stores/permission/ @jfcastro92 @Salazareo
/src/backend/stores/group/ @jfcastro92 @Salazareo
@@ -62,7 +59,7 @@
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer\* @ProgrammerIn-wonderland @Salazareo
# SDK: sharing and permissions
-106
View File
@@ -1,106 +0,0 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ai-provider-integration-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
'.github/workflows/backend-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'davelosert/[email protected]'
- 'dorny/[email protected]'
'.github/workflows/dependabot-automerge.yml':
- 'dependabot/[email protected]'
'.github/workflows/docker-image.yaml':
- 'actions/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'peter-evans/[email protected]'
'.github/workflows/docs-build.yml':
- 'actions/[email protected]'
- 'actions/[email protected]'
'.github/workflows/notify-dev.yaml': []
'.github/workflows/notify-prod.yaml': []
'.github/workflows/puterjs-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'davelosert/[email protected]'
- 'dorny/[email protected]'
'.github/workflows/tag-alias.yaml':
- 'docker/[email protected]'
- 'docker/[email protected]'
dependencies:
'actions/[email protected]':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/[email protected]':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/[email protected]':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/[email protected]':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'davelosert/[email protected]':
ref: 'v2.13.2'
commit: 'sha1-74c2d9210b4fc44c84d8213f61d7ec99837b7aa0'
owner_id: 4287128
repo_id: 501176456
'dependabot/[email protected]':
ref: 'v3.1.0'
commit: 'sha1-25dd0e34f4fe68f24cc83900b1fe3fe149efef98'
owner_id: 27347476
repo_id: 371068214
'docker/[email protected]':
ref: 'v7.4.0'
commit: 'sha1-c3c9e263c25d99ce0380d002d59b67737d91b0dc'
owner_id: 5429470
repo_id: 241092383
'docker/[email protected]':
ref: 'v4.8.0'
commit: 'sha1-8aa5831fb8d48d8f76594c7ddde61ae58019f550'
owner_id: 5429470
repo_id: 287743349
'docker/[email protected]':
ref: 'v6.2.0'
commit: 'sha1-dc802804100637a589fabce1cb79ff13a1411302'
owner_id: 5429470
repo_id: 306769011
'docker/[email protected]':
ref: 'v4.4.1'
commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069'
owner_id: 5429470
repo_id: 288485773
'docker/[email protected]':
ref: 'v4.4.0'
commit: 'sha1-99012661954931238ded8c8b007157a8430204e1'
owner_id: 5429470
repo_id: 288487894
'dorny/[email protected]':
ref: 'v4.0.3'
commit: 'sha1-ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d'
owner_id: 1588572
repo_id: 265601040
'peter-evans/[email protected]':
ref: 'v5.0.0'
commit: 'sha1-1b9a80c056b620d92cedb9d9b5a223409c68ddfa'
owner_id: 18365890
repo_id: 193685475
@@ -17,12 +17,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
+12 -18
View File
@@ -23,10 +23,8 @@ jobs:
outputs:
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: dorny/[email protected]
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -54,12 +52,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -84,14 +80,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -111,7 +106,7 @@ jobs:
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7.0.2
uses: actions/upload-artifact@v7
with:
name: backend-blob-${{ matrix.shard }}
path: .vitest-reports/
@@ -151,14 +146,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -167,7 +161,7 @@ jobs:
run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8
with:
pattern: backend-blob-*
path: .vitest-reports
@@ -179,7 +173,7 @@ jobs:
CI: 'true'
- name: Upload coverage artifact
uses: actions/upload-artifact@v7.0.2
uses: actions/upload-artifact@v7
with:
name: backend-coverage-${{ github.event_name == 'push' && 'main' || 'pr' }}
path: src/backend/coverage/
@@ -210,7 +204,7 @@ jobs:
- name: Report coverage on PR
if: github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2.13.2
uses: davelosert/vitest-coverage-report-action@v2
with:
json-summary-path: src/backend/coverage/coverage-summary.json
json-final-path: src/backend/coverage/coverage-final.json
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3.1.0
uses: dependabot/fetch-metadata@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+8 -10
View File
@@ -45,32 +45,30 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v4.8.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v4.8.0
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4.4.0
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4.4.1
uses: docker/setup-buildx-action@v4
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6.2.0
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -84,7 +82,7 @@ jobs:
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
- name: Build and push Docker image
uses: docker/build-push-action@v7.4.0
uses: docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -97,7 +95,7 @@ jobs:
cache-to: type=gha,mode=max
- name: Sync README to Docker Hub
uses: peter-evans/dockerhub-description@v5.0.0
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+2 -7
View File
@@ -5,21 +5,16 @@ on:
paths:
- 'src/docs/**'
permissions:
contents: read
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
+40
View File
@@ -0,0 +1,40 @@
name: Maintain Release Merge PR
on:
push:
branches:
- main
jobs:
update-release-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup Git
run: |
git config user.name github-actions
git config user.email [email protected]
- name: Check for existing PR
id: find-pr
uses: juliangruber/find-pull-request-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
branch: release
- uses: actions/checkout@v7
if: steps.find-pr.outputs.number == ''
with:
ref: release
- name: Reset release branch
if: steps.find-pr.outputs.number == ''
run: |
git fetch origin main:main
git reset --hard main
- name: Create/Update PR
if: steps.find-pr.outputs.number == ''
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update release branch
title: Merge Release Auto-PR
body: Merging this PR will invoke release actions
branch: auto-update/release
-2
View File
@@ -5,8 +5,6 @@ on:
branches:
- main
permissions: {}
jobs:
notify:
runs-on: ubuntu-latest
+14 -23
View File
@@ -22,10 +22,8 @@ jobs:
outputs:
puterjs: ${{ steps.filter.outputs.puterjs }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: dorny/[email protected]
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -54,12 +52,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -77,12 +73,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -136,15 +130,14 @@ jobs:
steps:
- name: Checkout ${{ matrix.ref }}
if: needs.changes.outputs.puterjs == 'true'
uses: actions/checkout@v7.0.1
uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ matrix.ref }}
repository: ${{ matrix.repository }}
- name: Set up Node.js
if: needs.changes.outputs.puterjs == 'true'
uses: actions/setup-node@v7.1.0
uses: actions/setup-node@v7
with:
node-version: '26'
cache: 'npm'
@@ -168,7 +161,7 @@ jobs:
- name: Upload coverage artifact
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
uses: actions/upload-artifact@v7.0.2
uses: actions/upload-artifact@v7
with:
name: puterjs-coverage-${{ matrix.artifact }}
path: src/puter-js/coverage/coverage-*.json
@@ -181,15 +174,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v7
# Missing when the PR leg failed; `coverage (pr)` already reports that.
- name: Download PR coverage
id: pr-coverage
continue-on-error: true
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-pr
path: src/puter-js/coverage
@@ -200,14 +191,14 @@ jobs:
id: base-coverage
if: steps.pr-coverage.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@v8.0.2
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-base
path: coverage-base
- name: Report coverage on PR
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
uses: davelosert/vitest-coverage-report-action@v2.13.2
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
@@ -216,7 +207,7 @@ jobs:
- name: Report coverage on PR (no base comparison)
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
uses: davelosert/vitest-coverage-report-action@v2.13.2
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
+18
View File
@@ -0,0 +1,18 @@
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v4
with:
release-type: node
+2 -2
View File
@@ -45,14 +45,14 @@ jobs:
fi
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v4.8.0
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4.4.1
uses: docker/setup-buildx-action@v4
- name: Create alias tag
env:
+1 -6
View File
@@ -9,7 +9,6 @@ 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), actors, 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/ai-drivers.md](doc/ai-drivers.md) — where AI driver code lives (interfaces, providers, protocols, shared), the import rules between those folders, and how to add a provider or an AI interface.
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
@@ -71,7 +70,7 @@ Commit messages and PR descriptions never credit anyone or mention a bug report.
- **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.** In files you're already touching, fix the typo, the dead import, the missing test, the bit you had to read twice. Don't ride a refactor along with a bug fix.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't have written, debugged, or defended in review is not.
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/ai/shared/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
---
@@ -98,10 +97,6 @@ Layer rules:
- **Drivers and controllers stay thin.** Domain rules a second entry point needs (signup, app CRUD, worker deploy, AI routing/billing) live in a service, not in the first driver or controller that needed them.
- **Prefer explicit arguments over [`Context`](src/backend/core/context.ts) (ALS).** Use it only for request-scoped values that would otherwise thread through many layers; today that's mostly `actor` and `req`.
### AI drivers
AI drivers live in [src/backend/drivers/ai/](src/backend/drivers/ai/): `interfaces/` holds the Puter-facing drivers, `providers/<vendor>/<interface>/` holds vendor code, `protocols/` holds wire protocols several vendors speak, and `shared/` holds helpers more than one interface uses. Follow [doc/ai-drivers.md](doc/ai-drivers.md) when adding or moving AI code.
### Extensions
[extensions/](extensions/) is for **non-crucial parts of the system**: Puter must still work with the extension removed. If core needs to call it, it belongs in core ([whoami](extensions/whoami.ts) is the cautionary example). Inside an extension, follow the same layered structure, unless it only needs a few route handlers, in which case the `extension.get/post/...` helpers are enough on their own. The `extension` API is in [doc/architecture.md](doc/architecture.md#extension-api).
-14
View File
@@ -62,10 +62,6 @@
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
// and makes X-Forwarded-For forgeable.
"trust_proxy": false,
// Request header a TLS-terminating proxy in front of the server sets to
// the client's TLS fingerprint (JA3/JA4). Only set it when that proxy
// overwrites any value the client sends. Unset disables it.
// "tlsFingerprintHeader": "x-tls-fingerprint",
"no_browser_launch": false,
// ── Dev watcher (devWatcher extension) ──────────────────────────────
@@ -258,16 +254,6 @@
"user": "",
"pass": ""
}
// Each send names a stream: "auth", "signup" or "notify". A stream
// listed here adds its headers to every message on it, and its "from"
// when the sender gives none. Tags are written to "tagsHeader".
// "streams": {
// "notify": {
// "from": "\"Puter\" <[email protected]>",
// "headers": { "X-Example-Stream": "notify" },
// "tagsHeader": "X-Example-Tags"
// }
// }
},
// ── Inbound mail (separate process) ─────────────────────────────────
-152
View File
@@ -1,152 +0,0 @@
# AI Drivers
Where the AI driver code lives under [src/backend/drivers/ai/](../src/backend/drivers/ai/), what each folder owns, and how to add a provider or a new AI interface. The general layer rules are in [architecture.md](architecture.md) and [AGENTS.md](../AGENTS.md#backend). This page covers what is specific to AI.
## Layout
```text
src/backend/drivers/ai/
├── interfaces/ Puter-facing drivers, one folder per AI interface
├── providers/ Vendor code, as providers/<vendor>/<interface>/
├── protocols/ Wire protocols that several vendors speak
└── shared/ Helpers that more than one interface uses
```
| Folder | Owns | Example |
| --- | --- | --- |
| `interfaces/<interface>/` | The driver class, the interface's contracts in `types.ts`, its provider base class where one exists, and the helpers and tests that apply to every provider of that interface. | [interfaces/chat/ChatCompletionDriver.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.ts) |
| `providers/<vendor>/<interface>/` | One vendor's implementation of one interface: the adapter, its model catalog and prices, its fixtures, and its tests. | [providers/gemini/video/](../src/backend/drivers/ai/providers/gemini/video/) |
| `providers/<vendor>/` | Helpers that several of that vendor's interfaces use. | [providers/gemini/geminiRest.ts](../src/backend/drivers/ai/providers/gemini/geminiRest.ts) |
| `protocols/openaiCompatible/` | The OpenAI wire protocol: the base provider class, message and usage conversion, and parameter mapping for Chat Completions and Responses. | [protocols/openaiCompatible/OpenAICompatProvider.ts](../src/backend/drivers/ai/protocols/openaiCompatible/OpenAICompatProvider.ts) |
| `shared/` | Helpers that more than one interface uses: limits, the provider registry, the remote catalog cache, job polling, streamed charging, and media input. | [shared/aiLimits.ts](../src/backend/drivers/ai/shared/aiLimits.ts) |
Helpers that non-AI code also uses stay in [src/backend/drivers/util/](../src/backend/drivers/util/): file input, data URIs, upstream error mapping, and stream piping.
AI code that a service also needs lives in [src/backend/util/](../src/backend/util/), because a service can't import from the driver layer. The realtime relay in `services/realtime/` bills through the same cost factor as the drivers, so these three files are there:
| File | Holds |
| --- | --- |
| [aiCostFactor.ts](../src/backend/util/aiCostFactor.ts) | `withAiCostFactor`, the metering view every AI driver and the realtime relay record usage through, and `withAiCreditHold`. |
| [aiProviderKey.ts](../src/backend/util/aiProviderKey.ts) | `readProviderKey`, which reads an API key from `config.providers` blocks. |
| [aiPrices.ts](../src/backend/util/aiPrices.ts) | Vendor prices that a driver and a service both bill. |
### Interfaces
| Folder | Driver | `driverInterface` | `driverName` | Key in `puterDrivers` |
| --- | --- | --- | --- | --- |
| `interfaces/chat/` | `ChatCompletionDriver` | `puter-chat-completion` | `ai-chat` | `aiChat` |
| `interfaces/image/` | `ImageGenerationDriver` | `puter-image-generation` | `ai-image` | `aiImage` |
| `interfaces/video/` | `VideoGenerationDriver` | `puter-video-generation` | `ai-video` | `aiVideo` |
| `interfaces/textToSpeech/` | `TTSDriver` | `puter-tts` | `ai-tts` | `aiTts` |
| `interfaces/speechToText/` | `SpeechToTextDriver` | `puter-speech2txt` | `ai-speech2txt` | `aiSpeech2Txt` |
| `interfaces/speechToSpeech/` | `VoiceChangerDriver` | `puter-speech2speech` | `ai-speech2speech` | `aiSpeech2Speech` |
| `interfaces/ocr/` | `OCRDriver` | `puter-ocr` | `ai-ocr` | `aiOcr` |
Folder names are not runtime identifiers. `driverInterface`, `driverName`, driver aliases, the keys under `providers` in config, provider ids, model ids, and usage keys are public contracts. Moving or renaming a file never changes them.
## Request flow
A call goes from the interface to a provider, and from there to a shared protocol when the vendor speaks one:
```text
interfaces/chat/ChatCompletionDriver.ts
validates the call, picks the model and provider, holds credits
→ providers/deepseek/chat/DeepSeekProvider.ts
DeepSeek's catalog, endpoint, and request differences
→ protocols/openaiCompatible/OpenAICompatProvider.ts
the OpenAI-compatible request, response, and stream handling
```
A provider whose vendor has its own API, such as `ClaudeProvider` or `ElevenLabsTTSProvider`, implements the interface's contract directly and uses no protocol.
## Import rules
- `shared/` imports nothing from `interfaces/`, `providers/`, or `protocols/`.
- Nothing in `services/`, `stores/`, or `clients/` imports from `drivers/ai/`. Code that a service and a driver both need goes in `src/backend/util/`.
- `providers/` and `protocols/` import an interface's `types.ts` and helpers. They never import a driver class. Their tests can, to drive a provider through the real driver.
- Only an interface's driver file imports providers, to register them. The interface's helpers don't.
- An interface doesn't import another interface. When a second interface needs a helper, move the helper to `shared/`, or to `providers/<vendor>/` when it belongs to one vendor.
- A vendor folder imports another vendor's folder only when it serves that vendor's models. `providers/azure/` reads the OpenAI and xAI catalogs for that reason.
## Where a file goes
| The code is | It goes in |
| --- | --- |
| Input validation, gates, routing, credit holds, or anything else every provider of the interface gets | `interfaces/<interface>/` |
| A request or response contract that providers implement | `interfaces/<interface>/types.ts` |
| A vendor's endpoint, request shape, catalog, prices, or quirks | `providers/<vendor>/<interface>/` |
| A vendor's auth, REST plumbing, or constants that two of its interfaces use | `providers/<vendor>/` |
| Behavior of a wire protocol that several vendors speak | `protocols/<protocol>/` |
| A helper that two or more interfaces use and that belongs to no vendor | `shared/` |
| A helper that non-AI drivers or controllers also use | `src/backend/drivers/util/` |
| AI code that a service also uses | `src/backend/util/`, in a file named `ai<Something>.ts` |
Keep a helper next to its only consumer. Move it up when a second consumer appears, not before.
When one Puter option becomes a different request shape per vendor, the interface reads the option and each dialect maps it. The chat `compaction` option is read in `interfaces/chat/utils/compaction.js`, mapped for OpenAI by `toOpenAiContextManagement` in `protocols/openaiCompatible/openaiParams.ts`, and mapped for Anthropic by `toAnthropicContextManagement` in `providers/claude/chat/contextManagement.ts`. Tools work the same way: `normalize_tools_object` in the interface, then `toolsToWire` for OpenAI and `claudeToolPolicy` for Anthropic.
Tests sit next to the code they test. Fixtures go in a `fixtures/` folder next to the test that reads them.
## Adding a provider
1. Create `providers/<vendor>/<interface>/`. Use the vendor's existing folder if it has one, so all of a vendor's interfaces stay together.
2. Implement the interface's contract from `interfaces/<interface>/types.ts`:
| Interface | Contract | Base class |
| --- | --- | --- |
| chat | `IChatProvider` | `OpenAICompatProvider` for an OpenAI-compatible vendor |
| image | `IImageProvider` | none |
| video | `IVideoProvider` | `VideoProvider` |
| textToSpeech | `ITTSProvider` | none |
| speechToText | `ISpeechToTextProvider` | `SpeechToTextProvider` |
| ocr | `IOcrProvider` | none |
Speech to speech has one provider, so `VoiceChangerDriver` uses `ElevenLabsSpeechToSpeechProvider` directly. Add a contract to its `types.ts` when a second provider needs one.
3. Put the model catalog and prices in the same folder, as `models.ts` or `costs.ts`. A provider that loads its catalog from the vendor wraps the fetch in `cachedRemoteCatalog` from `shared/`.
4. Register the provider in the interface's driver: in `#registerProviders()`, or in `onServerStart()` for OCR and speech to speech. A chat provider built from an API key and an optional base URL is one row in `KEYED_PROVIDERS` instead. Text to speech and speech to text also list the provider id and its aliases in `providerAliases.ts`.
5. Read credentials from `config.providers[<key>]`. The key is part of the self-hosting config contract, so reuse the vendor's existing key when it has one.
6. Add tests next to the provider. Mock the vendor SDK or `fetch` at the network boundary and drive the provider through the real driver where you can. A live test is a `*.integration.test.ts` file that skips itself, with `skipUnlessEnv` from [testEnv.ts](../src/backend/testEnv.ts), unless its `PUTER_TEST_AI_*` variable is set. Helpers for provider tests are in [shared/integrationTestUtil.ts](../src/backend/drivers/ai/shared/integrationTestUtil.ts).
7. For a chat provider, add its static catalog to `CATALOGS` in [modelCatalogs.test.ts](../src/backend/drivers/ai/interfaces/chat/modelCatalogs.test.ts) and the provider to [providerConsistency.test.ts](../src/backend/drivers/ai/interfaces/chat/providerConsistency.test.ts). The catalog test fails when a `providers/*/chat/` file with "model" in its name exports a catalog that isn't listed.
8. Add the provider's config key to the `providers` config of the interface's [fuzz suite](#fuzz-tests), and its id to the list of providers the suite expects.
9. Update the published docs for the models and limits the provider adds, following [contributing-apis.md](contributing-apis.md).
### OpenAI-compatible vendors
A vendor that speaks the OpenAI Chat Completions protocol extends `OpenAICompatProvider` and overrides only what differs. It still gets its own `providers/<vendor>/chat/` folder.
Vendors share one constants-only folder only when at least six of them differ in nothing but base URL and model catalog. Each then becomes one data file under `providers/openaiCompatible/providers/`, read by a single implementation. No vendor qualifies today, because every subclass overrides request, catalog, or billing behavior.
## Fuzz tests
Two seeded suites send malformed input through the real drivers and providers. They run with the rest of the backend tests.
| Suite | Covers |
| --- | --- |
| [interfaces/chat/ChatCompletionDriver.fuzz.test.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts) | Chat requests with a few values changed, and valid requests against wrong statuses, bodies of the wrong shape, and broken event streams. |
| [interfaces/mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts) | Image, video, speech and OCR requests with a few values changed. Every vendor refuses the request. |
What the suites assert:
- A malformed request never fails with an error that has no status, which `/drivers/call` would report as an unclassified 500.
- For chat, with the vendors answering, a malformed request also never fails with a 5xx and never marks a route unhealthy.
- When the upstream misbehaves, chat ends with an answer or an `HttpError`. A stream only has to end: the error line a stream carries isn't checked, so a malformed event can still end one with a 500.
The vendors are played at the HTTP boundary by `upstream` in [shared/fuzzTestUtil.ts](../src/backend/drivers/ai/shared/fuzzTestUtil.ts), which stands in for global `fetch`, undici `fetch` and axios, and makes `https.request` throw so an SDK with its own client fails the suite. The vendors' SDKs run, except Groq's, which is swapped for the OpenAI SDK, and the AWS clients, whose `send` is stubbed. The media suite calls the drivers as a user with unlimited metering, so a request gets past file input and the credit hold to the vendor. A provider that loads its catalog from the vendor registers no models there, so the suites skip it.
A failure lists each distinct finding with its case number and input. The random source is seeded, so the same case fails again on the next run. To search further, raise the case count:
```sh
PUTER_FUZZ_CASES=100000 npx vitest run --config src/backend/vitest.config.ts src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts
```
When a suite finds a crash, validate the value where the request enters, in the driver or in `normalize_single_message`, so every provider is covered. Reject only what crashed: a value a provider ignored or refused before must still reach it. An option a driver reads as text, a number or a flag goes in the driver's `SCALAR_OPTIONS`, and `assertScalarOptions` from `shared/scalarOptions.ts` answers 400 for a value there that can't be converted to text. Add a unit test for the case next to the fix.
## Adding an interface
1. Create `interfaces/<interface>/` with the driver class and a `types.ts` for the contracts providers implement.
2. Register the driver in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts).
3. Use `AI_RATE_LIMIT` and `AI_CONCURRENT` from `shared/aiLimits.ts`, and publish any new limit in [rate-limits-and-quotas.md](../src/docs/src/rate-limits-and-quotas.md).
4. Meter through `withAiCostFactor` from `src/backend/util/aiCostFactor.ts`, and route between providers with `ProviderRegistry` from `shared/providerRegistry.ts`.
5. List the options the providers read as text, a number or a flag in a `SCALAR_OPTIONS` array, and call `assertScalarOptions` from `shared/scalarOptions.ts` first in each method. Add the driver to [mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts).
6. Follow [contributing-apis.md](contributing-apis.md) for the puter.js method, types, docs, and tests.
-13
View File
@@ -42,19 +42,6 @@ block-beta
Extensions sit alongside this stack and can register into any layer; see [Extensions](#extensions).
### AI drivers
The AI drivers have their own layout under [src/backend/drivers/ai/](../src/backend/drivers/ai/):
| Folder | Holds |
| --- | --- |
| `interfaces/` | The Puter-facing drivers, one folder per AI interface (chat, image, video, text to speech, speech to text, speech to speech, OCR). |
| `providers/` | Vendor code, as `providers/<vendor>/<interface>/`. |
| `protocols/` | Wire protocols that several vendors speak, such as the OpenAI-compatible one. |
| `shared/` | Helpers that more than one interface uses. |
A call goes from an interface's driver to a provider, and from there to a shared protocol when the vendor speaks one. [ai-drivers.md](ai-drivers.md) has the ownership rules, the import rules, and the steps for adding a provider.
## Entry point: `PuterServer`
`PuterServer` is the bootstrap. It:
+6 -6
View File
@@ -16,7 +16,7 @@ Core subscribes the browser to a push service only when the server also publishe
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` keeps the new one, or subscribes again with Puter's stored key when the browser gave none and the event, the receipt or an earlier mark names the subscription that went. It writes `puter:subscription-changed:<scope>` to the `puter-push` IndexedDB as `{ at, oldEndpoint }`, the old endpoint kept from an earlier mark no page handled yet, else taken from the event or from the receipt, forgets the receipt kept for that scope, and posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so an open page runs `helpers/pushSync.js` on that message, after any run already in flight, and a later load does the same through the marker.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
## Receipts
@@ -59,9 +59,9 @@ Apps on their own origin do the same through `push-sw.js`, which they import int
```ts
interface PushClient {
enable(options?: { background?: boolean }): Promise<{ deviceUid: string }>;
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(options?: { cached?: boolean }): Promise<{
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
@@ -69,12 +69,12 @@ interface PushClient {
}
```
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app may push to Puter's devices (`puterDevices` in `GET /push/apps`); the browser does not prompt then. That call passes `{ background: true }`: a browser the user removed in Puter stays removed, and the built-in client rejects with code `push_device_removed`, after which core records the browser as turned off. Before a background call the built-in client asks `POST /push/web/subscriptions/status` about every earlier endpoint it knows: the marker's `oldEndpoint` and the last endpoint the server answered `active` for (`puter.push.last-endpoint:<user uuid>` in `localStorage`). On a 429 it registers nothing and rejects with `too_many_requests`, which the sync reports as `later`, keeping the marker for the next run. When any answer is `removed`, it ends the current subscription in the browser, clears the marker and rejects with `push_device_removed`, so a subscription change or a dropped subscription never brings back a browser removed from another device. A click renews it as before. For a click the built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`); in the background it runs the same registration without replacing a removed subscription or opening Puter's sign-in. It then resolves the `uid` of this browser's `webpush` device from `listDevices()` and clears the subscription-changed marker. It rejects when permission is refused or the browser wasn't registered.
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user and `POST /push/web/subscriptions/status` answers `active` for the current subscription's endpoint, and `deviceUid` is that device's `uid`. A server that can't answer leaves the device match alone, and so does an `unknown` for an endpoint `enable()` registered in this page in the last 5 minutes, since that check can trail a registration. While the subscription-changed marker is set, `registered` is false. With `{ cached: true }` a client may answer with the last registered state it read in this page. Core passes it for the silent registration on app launch and for an app's `puter.notifications.status()`; the Notifications tab and the push dialogs read fresh. The built-in client keeps that state in memory for the same user and subscription for up to 5 minutes, from `enable()` or once the server answered `active` for the endpoint, and answers from it without either request. Reads of the same user and endpoint share one real check while it is in flight. It drops it on `disable()`, a failed `enable()`, an unregistered answer, a set subscription-changed marker, and the worker's `puter-push-subscription-changed` message, so a new page load always asks the server. Whether the calling app may push is checked on every call, against a `GET /push/apps` answer the page reuses for 30 seconds and drops on `enable()`, `disable()`, a grant, a revoke and a user change. When it can't be read, or leaves the app out, the grant alone counts.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off, by Remove on this browser's device, and by a silent registration the server refuses with `push_device_removed`, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here or from another device stays removed until the user turns it on from a click, while a new sign-in in a browser that already allows notifications registers again.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
-14
View File
@@ -326,20 +326,6 @@ Used for password resets, email confirmation, and notifications. Without it thos
To require email confirmation before login, also set `"strict_email_verification_required": true`.
Account and notification mail is sent on a named stream: `auth` (password, address and 2FA changes), `signup` (address confirmation; a `puter.signup.validate` listener may name another: lowercase letters, digits, `:`, `_` and `-`, up to 64 characters, starting with a letter or digit) and `notify` (share, app feedback and contact form mail). Add `"streams"` inside `"email"` to configure one, for instance to let your mail provider route or report each kind of mail separately:
```json
"streams": {
"notify": {
"from": "\"Puter\" <[email protected]>",
"headers": { "X-Example-Stream": "notify" },
"tagsHeader": "X-Example-Tags"
}
}
```
`headers` are added to every message on the stream, `from` replaces the default sender, and `tagsHeader` names the header that carries a message's tags as `name=value` pairs joined by `, `. A stream that is not listed adds nothing: the message goes out with the default from address and the caller's headers. `streams` is never handed to the transport.
To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host:
```sh
+77 -86
View File
@@ -897,21 +897,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz",
"integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.13",
"@babel/helper-compilation-targets": "^7.29.13",
"@babel/helper-module-transforms": "^7.29.13",
"@babel/helpers": "^7.29.13",
"@babel/parser": "^7.29.13",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.13",
"@babel/types": "^7.29.11",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -938,14 +938,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz",
"integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.13",
"@babel/types": "^7.29.11",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -955,9 +955,9 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz",
"integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -982,15 +982,22 @@
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -1016,15 +1023,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz",
"integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.13"
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1044,9 +1051,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz",
"integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1074,27 +1081,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz",
"integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.11"
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz",
"integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.11"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1128,18 +1135,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz",
"integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.13",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.13",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.11",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1147,13 +1154,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.11",
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
@@ -7453,18 +7460,18 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.1.0",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.3",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.16.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
@@ -7477,9 +7484,9 @@
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9574,9 +9581,9 @@
}
},
"node_modules/fastify": {
"version": "5.12.7",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.7.tgz",
"integrity": "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==",
"version": "5.12.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz",
"integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==",
"funding": [
{
"type": "github",
@@ -16580,9 +16587,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.30.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.2.tgz",
"integrity": "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==",
"version": "7.30.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -18134,13 +18141,6 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -18233,9 +18233,9 @@
}
},
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -18300,7 +18300,7 @@
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.24.6",
"nodemailer": "^10.1.7",
"nodemailer": "^10.1.4",
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
@@ -18312,10 +18312,10 @@
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"undici": "^7.30.2",
"undici": "^7.30.1",
"uuid": "^14.0.3",
"validator": "^13.15.35",
"ws": "^8.22.2"
"ws": "^8.22.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
@@ -18343,19 +18343,10 @@
"dev": true,
"license": "MIT"
},
"src/backend/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"src/backend/node_modules/ws": {
"version": "8.22.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.2.tgz",
"integrity": "sha512-/rEZ2ejown2N5gxVTHUu9bVVC7ttu0/Tgzf5hbTOixR3Fs7mbbXgn2DbdbcKgJOmpQMtrIUWivtYuVMnp/W/yA==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -167,38 +167,6 @@ describe('AbstractDatabaseClient — replica-aware reads', () => {
expect(client.reads).toEqual(['SELECT 1']);
expect(client.preads).toEqual([]);
});
describe('tryHardReadAll', () => {
const reads = [{ query: 'SELECT 1' }, { query: 'SELECT 2' }];
it('keeps replica rows without reading the primary', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
expect(await client.tryHardReadAll(reads)).toEqual([
[{ id: 1 }],
[{ id: 1 }],
]);
expect(client.preads).toEqual([]);
});
it.each([[], new Error('replica unavailable')])(
'reads every query on the primary when the replica has none',
async (replica) => {
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
expect(await client.tryHardReadAll(reads)).toEqual([
[{ id: 2 }],
[{ id: 2 }],
]);
expect(client.preads).toEqual(['SELECT 1', 'SELECT 2']);
},
);
it('issues one read per query when there is no replica', async () => {
const client = new FixtureDatabaseClient([], [{ id: 2 }], false);
expect(await client.tryHardReadAll(reads)).toEqual([[], []]);
expect(client.reads).toEqual(['SELECT 1', 'SELECT 2']);
expect(client.preads).toEqual([]);
});
});
});
describe('AbstractDatabaseClient — SQL generation', () => {
@@ -172,34 +172,6 @@ export class AbstractDatabaseClient extends PuterClient {
return result.value;
}
/** Reads the primary only when every replica read is empty or fails. */
@Span('db.tryHardReadAll', (reads: unknown[]) => ({
'db.batch_size': reads.length,
}))
async tryHardReadAll(
reads: { query: string; params?: unknown[] }[],
): Promise<Record<string, unknown>[][]> {
if (!this.hasReadReplica()) {
return Promise.all(
reads.map(({ query, params = [] }) => this.read(query, params)),
);
}
const onReplica = await Promise.all(
reads.map(async ({ query, params = [] }) => {
try {
return await this.replicaRead(query, params);
} catch {
// A failed replica read counts as a miss.
return [];
}
}),
);
if (onReplica.some((rows) => rows.length > 0)) return onReplica;
return Promise.all(
reads.map(({ query, params = [] }) => this.pread(query, params)),
);
}
/**
* Apply the `.sql` files named `<engineName>*` in each configured migration
* directory, in filename order. Files must be idempotent: nothing records
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 101;
const CURRENT_SCHEMA_VERSION = 98;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -705,65 +705,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
}
});
it('fills a share row missing its entry owner from 0103, invites left alone', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-share-owner-'));
const path = join(dir, 'puter.sqlite');
try {
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await before.onServerStart();
const [user] = (await before.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await before.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
'VALUES (?, ?, ?, ?)',
['22222222-2222-4222-8222-222222222222', 'owned.txt', user.id, 0],
);
const [entry] = (await before.read(
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
['22222222-2222-4222-8222-222222222222'],
)) as { id: number }[];
await before.write(
'INSERT INTO `share` (`uid`, `issuer_user_id`, ' +
'`recipient_email`, `fsentry_id`) VALUES (?, ?, ?, ?)',
['share-no-owner', user.id, '[email protected]', entry.id],
);
await before.write(
'INSERT INTO `share` (`uid`, `issuer_user_id`, ' +
'`recipient_email`) VALUES (?, ?, ?)',
['share-invite', user.id, '[email protected]'],
);
// The full schema as it stood before 0103, so 0103 reruns; 0104
// is undone so it can apply again after it.
await before.write('DROP INDEX idx_user_signup_tls_fingerprint');
await before.write(
'ALTER TABLE `user` DROP COLUMN `signup_tls_fingerprint`',
);
await before.write('PRAGMA user_version = 98');
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
await expect(
after.read(
'SELECT `uid`, `entry_owner_user_id` FROM `share` ' +
'WHERE `uid` IN (?, ?) ORDER BY `uid`',
['share-invite', 'share-no-owner'],
),
).resolves.toEqual([
{ uid: 'share-invite', entry_owner_user_id: null },
{ uid: 'share-no-owner', entry_owner_user_id: user.id },
]);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('indexes notification.created_at for the retention sweep', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(rows.map((r) => String(r.name))).toContain(
@@ -137,9 +137,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[95, ['0100_notification-send-key.sql']],
[96, ['0101_user-scoped-handlers.sql']],
[97, ['0102_notification-scope-id-index.sql']],
[98, ['0103_share-entry-owner-backfill.sql']],
[99, ['0104_signup-tls-fingerprint.sql']],
[100, ['0105_sessions_kind_debug_app.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1,25 +0,0 @@
-- 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/>.
-- Fills `share`.`entry_owner_user_id` on any row that names an entry but was
-- written without the owner. Mirrors SQLite migration 0103. No per-file
-- applied-state tracking; a replay finds nothing to fill.
UPDATE `share` JOIN `fsentries` ON `fsentries`.`id` = `share`.`fsentry_id`
SET `share`.`entry_owner_user_id` = `fsentries`.`user_id`
WHERE `share`.`fsentry_id` IS NOT NULL
AND `share`.`entry_owner_user_id` IS NULL;
@@ -1,51 +0,0 @@
-- 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/>.
-- `user`.`signup_tls_fingerprint`. See
-- sqlite/0104_signup-tls-fingerprint.sql for the rationale. No per-file
-- applied-state tracking, so the column and index are added only when missing
-- and a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
DELIMITER //
CREATE PROCEDURE _puter_add_user_signup_tls_fingerprint()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'signup_tls_fingerprint'
) THEN
ALTER TABLE `user`
ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL,
ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_signup_tls_fingerprint'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_signup_tls_fingerprint`
(`signup_tls_fingerprint`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_user_signup_tls_fingerprint();
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
@@ -1,46 +0,0 @@
-- 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/>.
-- Add 'debug_app' to the `sessions.kind` ENUM: a short-lived app session an
-- operator creates on a user's behalf. Mirrors SQLite migration 0105.
--
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is a
-- no-op once 'debug_app' is in the ENUM.
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
DELIMITER //
CREATE PROCEDURE _puter_sessions_kind_debug_app()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND COLUMN_NAME = 'kind'
AND FIND_IN_SET('debug_app', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
) THEN
ALTER TABLE `sessions`
MODIFY COLUMN `kind`
ENUM('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')
NOT NULL DEFAULT 'web',
ALGORITHM=INSTANT;
END IF;
END//
DELIMITER ;
CALL _puter_sessions_kind_debug_app();
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
@@ -1,27 +0,0 @@
-- 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/>.
-- Fills "share".entry_owner_user_id on any row that names an entry but was
-- written without the owner. Mirrors SQLite migration 0103. No per-file
-- applied-state tracking; a replay finds nothing to fill.
UPDATE "share"
SET entry_owner_user_id = f.user_id
FROM fsentries f
WHERE f.id = "share".fsentry_id
AND "share".fsentry_id IS NOT NULL
AND "share".entry_owner_user_id IS NULL;
@@ -1,22 +0,0 @@
-- 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/>.
-- "user".signup_tls_fingerprint. Mirrors SQLite migration 0104. Idempotent
-- via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS signup_tls_fingerprint varchar(64);
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON "user" (signup_tls_fingerprint);
@@ -1,24 +0,0 @@
-- 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/>.
-- Add 'debug_app' to the `sessions.kind` CHECK constraint: a short-lived app
-- session an operator creates on a user's behalf. Mirrors SQLite migration
-- 0105. Drop-then-add keeps it idempotent across directory replays.
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_kind_check;
ALTER TABLE sessions ADD CONSTRAINT sessions_kind_check
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app'));
@@ -1,27 +0,0 @@
-- 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/>.
-- Fills `share`.`entry_owner_user_id` on any row that names an entry but was
-- written without the owner, so reads can rely on the column alone. Rows that
-- name no entry (invites) keep a NULL owner. A replay finds nothing to fill.
UPDATE `share`
SET `entry_owner_user_id` = (
SELECT `user_id` FROM `fsentries` WHERE `fsentries`.`id` = `share`.`fsentry_id`
)
WHERE `fsentry_id` IS NOT NULL
AND `entry_owner_user_id` IS NULL;
@@ -1,22 +0,0 @@
-- 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/>.
-- `user`.`signup_tls_fingerprint`: the TLS fingerprint of the request that
-- created the account, when a fronting proxy supplies one. Indexed so the
-- accounts created from one TLS stack can be listed.
ALTER TABLE `user` ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON `user` (`signup_tls_fingerprint`);
@@ -1,89 +0,0 @@
-- 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/>.
-- Add 'debug_app' to the `kind` CHECK constraint on `sessions`. Mirrors the
-- MySQL ENUM extension in mysql_mig_59.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the same
-- table-rebuild as migration 0056: create `sessions_new` with the corrected
-- constraint, copy rows, drop the old table, rename, then recreate every
-- index that lived on the original.
CREATE TABLE `sessions_new` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"uuid" TEXT NOT NULL,
"meta" JSON DEFAULT NULL,
"created_at" INTEGER DEFAULT 0,
"last_activity" INTEGER DEFAULT 0,
"kind" TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')),
"label" TEXT,
"parent_session_id" TEXT,
"last_ip" TEXT,
"last_user_agent" TEXT,
"revoked_at" INTEGER,
"expires_at" INTEGER,
"app_uid" TEXT,
"legacy_token_uid" TEXT,
"created_via" TEXT,
"auth_id" TEXT,
"access_token_uid" TEXT,
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `sessions_new` (
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
)
SELECT
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
FROM `sessions`;
DROP TABLE `sessions`;
ALTER TABLE `sessions_new` RENAME TO `sessions`;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
ON `sessions` (`user_id`, `app_uid`)
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
ON `sessions` (`legacy_token_uid`)
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
ON `sessions` (`kind`, `user_id`);
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
@@ -168,204 +168,6 @@ describe('EmailClient — sending', () => {
});
});
describe('EmailClient — streams', () => {
const STREAMS = {
notify: {
from: '"Notices" <[email protected]>',
headers: { 'X-Example-Stream': 'notify', 'X-Example-Kind': 'n' },
tagsHeader: 'X-Example-Tags',
},
plain: { headers: { 'X-Example-Stream': 'plain' } },
};
const streamClient = () =>
startClient({
email: { jsonTransport: true, from: FROM, streams: STREAMS },
} as unknown as Partial<IConfig>);
const base = { to: '[email protected]', subject: 's', text: 't' };
it('adds the stream headers and from address to the message', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
});
const message = sentMessage(info);
expect(message.headers).toMatchObject({
'X-Example-Stream': 'notify',
'X-Example-Kind': 'n',
});
expect(message.from.address).toBe('[email protected]');
});
it('lets caller headers and from win over the stream', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
from: '[email protected]',
headers: { 'x-example-stream': 'mine' },
});
const message = sentMessage(info);
expect(message.from.address).toBe('[email protected]');
expect(message.headers?.['x-example-stream']).toBe('mine');
expect(message.headers).not.toHaveProperty('X-Example-Stream');
expect(message.headers?.['X-Example-Kind']).toBe('n');
});
it('keeps the default from address for a stream without one', async () => {
const info = await streamClient().sendRaw({ ...base, stream: 'plain' });
expect(sentMessage(info).from.address).toBe('[email protected]');
});
it('leaves the message unchanged for an unknown or missing stream', async () => {
const client = streamClient();
for (const stream of ['nope', undefined]) {
const message = sentMessage(
await client.sendRaw({ ...base, stream, tags: { a: 'b' } }),
);
expect(message.from.address).toBe('[email protected]');
expect(message.headers ?? {}).toEqual({});
}
});
it('renders valid tags into the stream tags header and drops the rest', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
tags: {
kind: 'share',
'app.id': 'a-1:x',
'bad key': 'v',
ok: 'bad value',
long: 'x'.repeat(129),
},
});
expect(sentMessage(info).headers?.['X-Example-Tags']).toBe(
'kind=share, app.id=a-1:x',
);
});
it('keeps a tags header the caller set', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
headers: { 'X-Example-Tags': 'own=1' },
tags: { kind: 'share' },
});
expect(sentMessage(info).headers?.['X-Example-Tags']).toBe('own=1');
});
it('sends no tags header when the stream names none or no tag survives', async () => {
const client = streamClient();
const plain = sentMessage(
await client.sendRaw({
...base,
stream: 'plain',
tags: { kind: 'share' },
}),
);
expect(plain.headers).toEqual({ 'X-Example-Stream': 'plain' });
const dropped = sentMessage(
await client.sendRaw({
...base,
stream: 'notify',
tags: { 'bad key': 'v' },
}),
);
expect(dropped.headers).not.toHaveProperty('X-Example-Tags');
});
it('does not hand stream or tags to the transport', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
tags: { kind: 'share' },
});
const raw = JSON.parse((info as { message: string }).message);
expect(raw).not.toHaveProperty('stream');
expect(raw).not.toHaveProperty('tags');
});
it('keeps streams out of the transport options', async () => {
const nodemailer = (await import('nodemailer')).default;
const create = vi.spyOn(nodemailer, 'createTransport');
try {
streamClient();
expect(create).toHaveBeenCalledTimes(1);
const [options] = create.mock.calls[0] as [Record<string, unknown>];
expect(options).not.toHaveProperty('streams');
expect(options).not.toHaveProperty('from');
expect(options).toMatchObject({ jsonTransport: true });
} finally {
create.mockRestore();
}
});
it('forwards the template send options to sendRaw', async () => {
const client = streamClient();
const sendRaw = vi.spyOn(client, 'sendRaw').mockResolvedValue(null);
await client.send(
'[email protected]',
'email_verification_code',
{ code: '1' },
{ stream: 'notify', replyTo: '[email protected]' },
);
expect(sendRaw).toHaveBeenCalledWith(
expect.objectContaining({
stream: 'notify',
replyTo: '[email protected]',
}),
);
});
it('applies the stream from address to a template send', async () => {
const info = await streamClient().send(
'[email protected]',
'email_verification_code',
{ code: '1' },
{ stream: 'notify' },
);
expect(sentMessage(info).from.address).toBe('[email protected]');
});
it('reads a stream only from its own config entry', async () => {
const inherited = Object.create({
base: { from: '[email protected]', headers: { 'X-Example-B': '1' } },
});
const client = startClient({
email: { jsonTransport: true, from: FROM, streams: inherited },
} as unknown as Partial<IConfig>);
const message = sentMessage(
await client.sendRaw({ ...base, stream: 'base' }),
);
expect(message.from.address).toBe('[email protected]');
expect(message.headers ?? {}).toEqual({});
});
it('prints the stream name and no tags when no transport is configured', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const client = new EmailClient({
port: 0,
extensions: [],
} as unknown as IConfig);
await client.sendRaw({
...base,
stream: 'notify',
tags: { kind: 'share' },
});
const printed = warn.mock.calls[0][1] as Record<string, unknown>;
expect(printed).not.toHaveProperty('tags');
expect(printed.stream).toBe('notify');
} finally {
warn.mockRestore();
}
});
});
describe('EmailClient — template rendering', () => {
it('renders the code into both subject and body', async () => {
const client = startClient();
+8 -65
View File
@@ -69,50 +69,14 @@ export interface SendMailOptions {
attachments?: EmailAttachment[];
/** Extra message headers (e.g. List-Unsubscribe), passed to the transport. */
headers?: Record<string, string>;
/** Names the `email.streams` entry whose from address and headers apply. */
stream?: string;
/** Rendered into the stream's `tagsHeader`; ignored when it has none. */
tags?: Record<string, string>;
}
/** Options a templated {@link EmailClient.send} forwards to `sendRaw`. */
export type SendTemplateOptions = Pick<SendMailOptions, 'replyTo' | 'stream'>;
const TAG_PART = /^[A-Za-z0-9_.:-]{1,128}$/;
interface CompiledTemplate {
subject: ReturnType<typeof template>;
html: ReturnType<typeof template>;
text?: ReturnType<typeof template>;
}
/** Pairs whose key or value falls outside `TAG_PART` are dropped. */
const renderTags = (tags: Record<string, string> | undefined): string =>
Object.entries(tags ?? {})
.filter(([name, value]) => TAG_PART.test(name) && TAG_PART.test(value))
.map(([name, value]) => `${name}=${value}`)
.join(', ');
const hasHeader = (
headers: Record<string, string> | undefined,
name: string,
): boolean =>
Object.keys(headers ?? {}).some(
(key) => key.toLowerCase() === name.toLowerCase(),
);
// Header names are case-insensitive: `x-a` from `over` replaces `X-A` in `base`.
const mergeHeaders = (
base: Record<string, string> | undefined,
over: Record<string, string> | undefined,
): Record<string, string> => {
const merged: Record<string, string> = {};
for (const [key, value] of Object.entries(base ?? {})) {
if (!hasHeader(over, key)) merged[key] = value;
}
return { ...merged, ...over };
};
// -- EmailClient ------------------------------------------------------
/**
@@ -147,8 +111,7 @@ export class EmailClient extends PuterClient {
return;
}
const { streams: _streams, from: _from, ...transportConf } = emailConf;
this.transport = nodemailer.createTransport(transportConf);
this.transport = nodemailer.createTransport(emailConf);
console.log('[email] transport configured');
}
@@ -162,8 +125,7 @@ export class EmailClient extends PuterClient {
/**
* Render a template and send it to `to`. `options.replyTo` sets the
* Reply-To header (e.g. so a recipient can respond to the originator of the
* message rather than the no-reply From address); `options.stream` is
* passed to {@link sendRaw}.
* message rather than the no-reply From address).
*
* Returns the transport's send result, or `null` when none is configured —
* {@link sendRaw} drops the message instead of throwing. A caller that
@@ -173,7 +135,7 @@ export class EmailClient extends PuterClient {
to: string,
template: T,
values: Record<string, unknown> = {},
options: SendTemplateOptions = {},
options: { replyTo?: string } = {},
) {
const compiled = this.compiledTemplates[template];
if (!compiled) {
@@ -181,12 +143,12 @@ export class EmailClient extends PuterClient {
}
return await this.sendRaw({
from: this.defaultFrom(),
to,
subject: compiled.subject(values),
html: compiled.html(values),
...(compiled.text ? { text: compiled.text(values) } : {}),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
...(options.stream ? { stream: options.stream } : {}),
});
}
@@ -197,38 +159,19 @@ export class EmailClient extends PuterClient {
* Returns the transport's send result, or `null` when no transport is
* configured (the send is a no-op in that case — callers that must not
* silently drop mail should check `config.email` before composing, or the
* `null` return after). A configured `stream` supplies the from address
* when the caller gives none, and headers the caller's own override.
* `null` return after).
*/
async sendRaw(options: SendMailOptions) {
const { stream: streamName, tags, ...message } = options;
if (!this.transport) {
console.warn(
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
{ ...message, ...(streamName ? { stream: streamName } : {}) },
options,
);
return null;
}
const streams = this.config.email?.streams;
const stream =
streamName && streams && Object.hasOwn(streams, streamName)
? streams[streamName]
: undefined;
let headers = mergeHeaders(stream?.headers, message.headers);
const tagsValue = stream?.tagsHeader ? renderTags(tags) : '';
if (
stream?.tagsHeader &&
tagsValue &&
!hasHeader(message.headers, stream.tagsHeader)
) {
headers = mergeHeaders(headers, {
[stream.tagsHeader]: tagsValue,
});
}
return await this.transport.sendMail({
...message,
...(Object.keys(headers).length > 0 ? { headers } : {}),
from: message.from ?? stream?.from ?? this.defaultFrom(),
...options,
from: options.from ?? this.defaultFrom(),
});
}
-26
View File
@@ -20,7 +20,6 @@ import type {
Response as ExpressResponse,
} from 'express';
import { Actor } from '../../core';
import type { EmailTemplateName } from '../email/templates';
import type { UsageInput } from '../../services/metering/types';
import { FSEntry } from '../../stores/fs/FSEntry';
@@ -241,8 +240,6 @@ export type EventMap = {
trail_id?: string;
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: string | null;
/** TLS fingerprint of the signup request, when a proxy supplies one. */
tls_fingerprint?: string | null;
/**
* Set by the abuse harness — require SMS phone verification
* post-signup.
@@ -250,12 +247,6 @@ export type EventMap = {
requires_phone_verification?: boolean;
/** Set by the abuse harness — require card verification post-signup. */
requires_card_verification?: boolean;
/**
* The email stream the confirmation message is sent on: lowercase
* letters, digits, `:`, `_` and `-`, up to 64 characters, starting with
* a letter or digit. Any other value is ignored.
*/
email_stream?: string | null;
[key: string]: unknown;
};
'puter.signup.success': {
@@ -307,8 +298,6 @@ export type EventMap = {
message: string | null;
[key: string]: unknown;
};
/** Before notification emails; a listener clears `allow` per recipient. */
'email.notify.validate': EmailNotifyValidateEvent;
// Inbound mail to a feedback address (`abuse@`, `fbl@`, ...), emitted via
// `emitAndWait`. A listener that takes it sets `handled` and it reaches no
// mailbox; otherwise it is routed like any other message.
@@ -990,21 +979,6 @@ export type EventMap = {
'outer.pubsub.metering.credits-changed': { userUuid: string };
} & IExtensionEventMap;
/** A union of `Names`, each of which has to be an email template. */
type TemplateNames<Names extends EmailTemplateName> = Names;
/** The emails that ask `email.notify.validate`, by template name. */
export type EmailNotifyKind = TemplateNames<
'file_shared_with_you' | 'file_shared_invite' | 'app-user-feedback'
>;
/** Payload for `email.notify.validate`: one kind of mail, all its recipients. */
export type EmailNotifyValidateEvent = {
kind: EmailNotifyKind;
/** `to` is the address as it will be sent; `allow` starts true. */
recipients: Array<{ to: string; allow: boolean }>;
};
/** Payload for `subscription.gate.<surface>` events. */
export type SubscriptionGateEvent = {
actor: Actor;
@@ -132,28 +132,6 @@ describe('PreludeClient', () => {
});
});
it('forwards a JA4 TLS fingerprint and drops any other shape', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
await client.createVerification('+14155550123', {
tls_fingerprint: ja4,
});
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
// A JA3 hash.
tls_fingerprint: 'e7d705a3286e19ea42f587b344ee6865',
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body).signals).toEqual({
ja4_fingerprint: ja4,
});
expect(JSON.parse(fetchMock.mock.calls[1][1].body).signals).toEqual({
ip: '203.0.113.7',
});
});
it('omits the signals object entirely when none are supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
@@ -25,8 +25,6 @@ const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
const REQUEST_TIMEOUT_MS = 8000;
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
const PRELUDE_CODE_SIZE = 6;
const JA4_SHAPE =
/^[tqd][0-9a-z]{2}[di]\d{4}[0-9a-z]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/;
/**
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
@@ -136,8 +134,6 @@ export class PreludeClient extends PuterClient {
ip?: string;
device_id?: string;
user_agent?: string;
/** Forwarded only when it is a JA4 value. */
tls_fingerprint?: string;
dispatch_id?: string;
} = {},
): Promise<{
@@ -172,8 +168,6 @@ export class PreludeClient extends PuterClient {
if (signals.ip) sig.ip = signals.ip;
if (signals.device_id) sig.device_id = signals.device_id;
if (signals.user_agent) sig.user_agent = signals.user_agent;
if (signals.tls_fingerprint && JA4_SHAPE.test(signals.tls_fingerprint))
sig.ja4_fingerprint = signals.tls_fingerprint;
if (Object.keys(sig).length > 0) body.signals = sig;
// dispatch_id is a top-level field, not a member of `signals`.
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
@@ -26,7 +26,6 @@ import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { tokenClaimsDigest } from '../../../gui/src/helpers/guiToken.js';
import {
createTestUser,
lagReadReplica,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
@@ -352,98 +351,6 @@ describe('revoke-own-access-token over HTTP', () => {
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
const ACCESS_TOKEN_QUERIES =
/FROM `access_token_permissions`|`access_token_uid` = \?/;
const tokenUidOf = (token: string) =>
(
env.server.services.token.verify('auth', token) as {
token_uid: string;
}
).token_uid;
it('serves a read URL fetched right after minting while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
try {
const readToken = await mintReadToken(owner.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const cached = await env.server.clients.redis.get(
`perms:token:${tokenUidOf(readToken)}`,
);
expect(cached).toBe(JSON.stringify([`fs:${file.uid}:read`]));
} finally {
replica.catchUp();
}
});
it('mints from a just-created personal API token while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
try {
const pat = await mintPersonalToken(owner);
const readToken = await mintReadToken(pat, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
} finally {
replica.catchUp();
}
});
it('revokes a just-created token by its uuid while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
let readToken: string;
try {
readToken = await mintReadToken(owner.token, file.uid);
await env.server.services.auth.revokeAccessToken(
actor!,
tokenUidOf(readToken),
);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
} finally {
replica.catchUp();
}
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
expect(await whoamiStatus(readToken)).toBe(401);
});
it('keeps a revoked token dead while the replica still shows it live', async ({
skip,
}) => {
// The frozen replica is a sqlite copy.
skip(process.env.PUTER_TEST_DB_ENGINE?.toLowerCase() === 'postgres');
const owner = env.users.user;
const file = await makeFile(owner);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
const readToken = await mintReadToken(owner.token, file.uid);
const tokenUid = tokenUidOf(readToken);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES, {
mode: 'frozen',
});
try {
await env.server.services.auth.revokeAccessToken(actor!, tokenUid);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
expect(
await env.server.stores.permission.listAccessTokenPerms(
tokenUid,
),
).toEqual([]);
expect(
await env.server.clients.redis.get(`perms:token:${tokenUid}`),
).toBeNull();
} finally {
replica.catchUp();
}
expect(await whoamiStatus(readToken)).toBe(401);
});
// These three run last in the file's shared `env.users.user`: each one
// revokes a session belonging to `owner.token` / `owner.workerToken`, so
// later tests can't rely on those credentials still being live.
@@ -487,10 +394,7 @@ describe('revoke-own-access-token over HTTP', () => {
it('a revoked GUI session still gets a reauth_token', async () => {
const owner = env.users.user;
const decoded = env.server.services.token.verify(
'auth',
owner.token,
) as {
const decoded = env.server.services.token.verify('auth', owner.token) as {
session_uid: string;
};
await env.server.stores.session.removeByUuid(decoded.session_uid);
@@ -656,6 +560,7 @@ describe('2FA enrollment over HTTP', () => {
expect(enabled.status).toBe(200);
expect((await otpState(account.userId)).enabled).toBe(true);
});
});
/**
@@ -30,7 +30,6 @@
import { installSeatStub } from '../../testFixtures/seatStub.js';
import bcrypt from 'bcrypt';
import { inspect } from 'node:util';
import jwt from 'jsonwebtoken';
import { TOTP } from 'otpauth';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
@@ -722,36 +721,6 @@ describe('AuthController.handleSignup', () => {
expect(audit.ip_fwd).toBe(forged);
});
it('records the request TLS fingerprint on the validate hook and the user row', async () => {
const username = `s_${uniq()}`;
const tlsFingerprint = 't13d1516h2_8daaf6152771_02713d6af862';
let seenTlsFingerprint: unknown;
await withSignupValidateOverride(
(event) => {
seenTlsFingerprint = (event as { tls_fingerprint?: unknown })
.tls_fingerprint;
},
async () => {
await controller.handleSignup(
{
...makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
tlsFingerprint,
},
makeRes(),
);
},
);
expect(seenTlsFingerprint).toBe(tlsFingerprint);
const persisted = await server.stores.user.getByUsername(username);
expect(persisted!.signup_tls_fingerprint).toBe(tlsFingerprint);
});
it('forces phone verification on every signup when always_require_phone_verification is set', async () => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.always_require_phone_verification;
@@ -4796,24 +4765,14 @@ describe('AuthController.handleSendConfirmEmail', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
it('rotates the email_confirm_code and reports the send', async () => {
it('rotates the email_confirm_code and returns {} on success', async () => {
const { user, actor } = await makeUserAndActor();
const before = await server.stores.user.getById(user.id, {
force: true,
});
const send = vi
.spyOn(server.clients.email, 'send')
.mockResolvedValue({ messageId: 'm' } as never);
const res = makeRes();
try {
await controller.handleSendConfirmEmail(
makeReq({}, { actor }),
res,
);
} finally {
send.mockRestore();
}
expect(res.body).toEqual({ sent: true });
await controller.handleSendConfirmEmail(makeReq({}, { actor }), res);
expect(res.body).toEqual({});
const after = await server.stores.user.getById(user.id, {
force: true,
});
@@ -4823,7 +4782,7 @@ describe('AuthController.handleSendConfirmEmail', () => {
// The resend is the recovery path for a lost code, so it must not fail
// silently either.
it('alarms, and still answers 200 with sent: false, when the resend send throws', async () => {
it('alarms — and still returns {} — when the resend send throws', async () => {
const { actor } = await makeUserAndActor();
const alarm = vi
.spyOn(server.clients.alarm, 'create')
@@ -4838,8 +4797,7 @@ describe('AuthController.handleSendConfirmEmail', () => {
res,
);
// Not the caller's fault; still a 200.
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ sent: false });
expect(res.body).toEqual({});
const raised = alarm.mock.calls.find(
(c) => c[0] === 'auth:confirmation-email-send-failed:resend',
@@ -7560,46 +7518,6 @@ describe('AuthController.handleSaveAccount', () => {
};
};
it('sends the confirmation code on the account confirmation stream', async () => {
const { user, actor } = await makeTempActor();
await server.stores.user.updateMetadata(user.id, {
emailStream: 'review',
});
const send = vi
.spyOn(server.clients.email, 'send')
.mockResolvedValue(null);
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
try {
const username = `s_${uniq()}`;
await controller.handleSaveAccount(
makeReq(
{
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
},
{ actor },
),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
`${username}@test.local`,
'email_verification_code',
expect.anything(),
{ stream: 'review' },
);
// The dropped send is reported under its own stage.
expect(alarm.mock.calls.map((c) => c[0])).toEqual([
'auth:confirmation-email-send-failed:save-account',
]);
} finally {
send.mockRestore();
alarm.mockRestore();
}
});
it('rejects non-temp accounts with 400', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -9591,18 +9509,14 @@ describe('AuthController.handleSignup additional branches', () => {
},
async () => {
const username = `cefail_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
makeRes(),
);
expect(res.body).toMatchObject({
confirmation_email_sent: false,
});
},
);
@@ -9623,93 +9537,6 @@ describe('AuthController.handleSignup additional branches', () => {
});
});
describe('AuthController.handleSignup confirmation email outcome', () => {
const signupWithSend = async (send: () => Promise<unknown>) => {
const spy = vi
.spyOn(server.clients.email, 'send')
.mockImplementation(send as never);
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
const res = makeRes();
const username = `ceo_${uniq()}`;
let alarms: string[] = [];
try {
await withSignupValidateOverride(
(event) => {
event.requires_email_confirmation = true;
},
() =>
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
),
);
alarms = alarm.mock.calls.map((c) => String(c[0]));
} finally {
spy.mockRestore();
alarm.mockRestore();
}
return {
body: res.body as {
proceed: boolean;
confirmation_email_sent?: boolean;
user: { requires_email_confirmation: boolean };
},
alarms,
};
};
it('reports a send that throws and still completes the signup', async () => {
const { body, alarms } = await signupWithSend(async () => {
throw new Error('refused');
});
expect(body.proceed).toBe(true);
expect(body.confirmation_email_sent).toBe(false);
expect(body.user.requires_email_confirmation).toBeTruthy();
expect(alarms).toContain('auth:confirmation-email-send-failed:signup');
});
it('reports a send the transport accepted', async () => {
const { body } = await signupWithSend(async () => ({ messageId: 'm' }));
expect(body.confirmation_email_sent).toBe(true);
});
it('keeps the confirmation stream out of the returned user details', async () => {
const res = makeRes();
const username = `ces_${uniq()}`;
await withSignupValidateOverride(
(event) => {
(event as Record<string, unknown>).email_stream = 'review';
},
() =>
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
),
);
const stored = await server.stores.user.getByUsername(username);
expect(stored!.metadata?.emailStream).toBe('review');
const details = (res.body as { user: { metadata?: object } }).user;
expect(details.metadata ?? {}).not.toHaveProperty('emailStream');
});
it('leaves the field out when no confirmation email is due', async () => {
const res = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), res);
expect(res.body).toMatchObject({ proceed: true });
expect(res.body).not.toHaveProperty('confirmation_email_sent');
});
});
describe('AuthController.handleSendPassRecoveryEmail additional branches', () => {
it('rejects an invalid email format with 400 (when no username supplied)', async () => {
await expect(
@@ -10805,10 +10632,10 @@ describe('AuthController.loginWait audience binding', () => {
// party holding the session id is somewhere else entirely.
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: 'https://evil.test' }],
{ during: () => setToken(session, token) },
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: 'https://evil.test' },
token,
);
// Same 408 the empty path returns — a mismatched caller must not be
// able to tell "nothing arrived" from "something arrived for someone
@@ -10884,10 +10711,10 @@ describe('AuthController.loginWait audience binding', () => {
);
const guiToken = (loginRes.body as { token: string }).token;
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, guiToken) },
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: OPENER },
guiToken,
);
expect(outcome).toMatchObject({ statusCode: 408 });
expect(res.body).toBeUndefined();
@@ -10902,406 +10729,14 @@ describe('AuthController.loginWait audience binding', () => {
{ keyid: 'v2' },
);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, forged) },
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: OPENER },
forged,
);
expect(outcome).toMatchObject({ statusCode: 408 });
expect(res.body).toBeUndefined();
});
/** Post a token to `/login/set` with no wait running. */
const setToken = (session: string, token: string) =>
controller.loginSet(makeReq({ session, auth_token: token }), makeRes());
/**
* Run waits on fake timers so the 10 s window passes without real waiting.
* `during` runs once every wait has subscribed; `now` pins the clock.
*/
const runWaits = async (
calls: Array<{ session: string; origin: string; clientGone?: boolean }>,
opts: { now?: number; during?: () => Promise<unknown> } = {},
) => {
const subscribed = vi.spyOn(eventClient, 'on');
vi.useFakeTimers({
toFake:
opts.now === undefined
? ['setTimeout']
: ['setTimeout', 'Date'],
});
if (opts.now !== undefined) vi.setSystemTime(opts.now);
const tick = () => new Promise((r) => setImmediate(r));
try {
let pending = calls.length;
const runs = calls.map(({ session, origin, clientGone }) => {
const res = makeRes();
const req = makeReq({ session }, { headers: { origin } });
if (clientGone) {
Object.assign(req.socket, { destroyed: true });
}
const outcome = controller
.loginWait(req, res)
.then(
() => 'ok' as const,
(e: unknown) => e,
)
.finally(() => {
pending--;
});
return { res, outcome };
});
const allSubscribed = () =>
calls.every(({ session }) =>
subscribed.mock.calls.some(
([key]) => key === `pubsub.login.${session}`,
),
);
for (let i = 0; i < 200 && pending > 0 && !allSubscribed(); i++) {
await tick();
}
for (let i = 0; i < 20; i++) await tick();
await opts.during?.();
for (let i = 0; i < 200 && pending > 0; i++) {
await tick();
await vi.advanceTimersByTimeAsync(1000);
}
return await Promise.all(
runs.map(async ({ res, outcome }) => ({
res,
outcome: await outcome,
})),
);
} finally {
vi.useRealTimers();
subscribed.mockRestore();
}
};
const waitAlone = async (session: string, origin: string, now?: number) =>
(await runWaits([{ session, origin }], { now }))[0];
it('delivers a token set before the wait subscribes', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('delivers a token set between one wait timing out and the next', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const first = await waitAlone(session, OPENER);
expect(first.outcome).toMatchObject({ statusCode: 408 });
await setToken(session, token);
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('hands a token out only once', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
expect((await waitAlone(session, OPENER)).outcome).toBe('ok');
const again = await waitAlone(session, OPENER);
expect(again.outcome).toMatchObject({ statusCode: 408 });
expect(again.res.body).toBeUndefined();
});
it('withholds a stored token from a mismatched Origin and keeps it for the matching one', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const other = await waitAlone(session, 'https://other.test');
expect(other.outcome).toMatchObject({ statusCode: 408 });
expect(other.res.body).toBeUndefined();
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('drops a stored token after a couple of minutes', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const early = uuidv4();
const late = uuidv4();
await setToken(early, token);
await setToken(late, token);
const start = Date.now();
expect((await waitAlone(early, OPENER, start + 110_000)).outcome).toBe(
'ok',
);
const expired = await waitAlone(late, OPENER, start + 121_000);
expect(expired.outcome).toMatchObject({ statusCode: 408 });
});
it('keeps waiting past a stored token for another app and delivers the real one', async () => {
const { actor } = await makeUserAndActor();
const otherToken = await mintAppToken(actor, 'https://other.test');
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, otherToken);
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: () => setToken(session, token),
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('still relays to a subscribed wait when storing the token fails', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failingSet = vi
.spyOn(server.clients.redis, 'set')
.mockRejectedValueOnce(new Error('unavailable'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
let setRes: MockRes | undefined;
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
setRes = makeRes();
await controller.loginSet(
makeReq({ session, auth_token: token }),
setRes,
);
},
},
);
expect(setRes?.body).toEqual({ success: true });
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
} finally {
failingSet.mockRestore();
warn.mockRestore();
}
});
it('removes the stored copy once a subscribed wait takes the token', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const { outcome } = await waitWithRelay(
session,
{ origin: OPENER },
token,
);
expect(outcome).toBe('ok');
const later = await waitAlone(session, OPENER);
expect(later.outcome).toMatchObject({ statusCode: 408 });
});
it('hands a stored token to only one of two concurrent waits', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const results = await runWaits([
{ session, origin: OPENER },
{ session, origin: OPENER },
]);
const outcomes = results.map((r) => r.outcome);
expect(outcomes.filter((o) => o === 'ok')).toHaveLength(1);
expect(outcomes.filter((o) => o !== 'ok')).toEqual([
expect.objectContaining({ statusCode: 408 }),
]);
});
it('relays a token to every wait already subscribed', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const results = await runWaits(
[
{ session, origin: OPENER },
{ session, origin: OPENER },
],
{ during: () => setToken(session, token) },
);
expect(results.map((r) => r.outcome)).toEqual(['ok', 'ok']);
for (const { res } of results) {
expect((res.body as { auth_token: string }).auth_token).toBe(token);
}
});
it('keeps the stored copy when the subscribed wait has lost its client', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await runWaits([{ session, origin: OPENER, clientGone: true }], {
during: () => setToken(session, token),
});
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('still relays to a subscribed wait when the stored copy cannot be read or removed', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failingGet = vi
.spyOn(server.clients.redis, 'get')
.mockRejectedValue(new Error('unavailable'));
const failingEval = vi
.spyOn(server.clients.redis, 'eval')
.mockRejectedValue(new Error('unavailable'));
try {
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, token) },
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
} finally {
failingGet.mockRestore();
failingEval.mockRestore();
}
});
it('answers a subscribed wait when a second token is set right after the first', async () => {
const { actor } = await makeUserAndActor();
const first = await mintAppToken(actor, OPENER);
// A later `iat` makes the second token differ from the first.
await new Promise((r) => setTimeout(r, 1100));
const second = await mintAppToken(actor, OPENER);
expect(second).not.toBe(first);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
await setToken(session, first);
await setToken(session, second);
},
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(first);
});
it('keeps a subscribed wait listening past a token for another app', async () => {
const { actor } = await makeUserAndActor();
const otherToken = await mintAppToken(actor, 'https://other.test');
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
await setToken(session, otherToken);
await setToken(session, token);
},
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it("keeps a stored token when a later set carries another app's token or an unsigned one", async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const otherToken = await mintAppToken(actor, 'https://other.test');
const session = uuidv4();
await setToken(session, token);
await setToken(session, otherToken);
await setToken(session, 'x'.repeat(100_000));
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('stores only a token this server signed for an app', async () => {
const { actor } = await makeUserAndActor();
const real = await mintAppToken(actor, OPENER);
const forged = jwt.sign(
jwt.decode(real) as object,
'not-the-server-secret',
{ keyid: 'v2' },
);
const set = vi.spyOn(server.clients.redis, 'set');
try {
const session = uuidv4();
await setToken(session, forged);
await setToken(session, 'x'.repeat(100_000));
expect(set).not.toHaveBeenCalled();
await setToken(session, real);
expect(set).toHaveBeenCalledTimes(1);
} finally {
set.mockRestore();
}
});
it('leaves the token out of the warning when storing it fails', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failure = Object.assign(new Error('ERR unavailable'), {
command: { name: 'set', args: [session, token, 'EX', '120'] },
});
const failingSet = vi
.spyOn(server.clients.redis, 'set')
.mockRejectedValueOnce(failure);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = makeRes();
await controller.loginSet(
makeReq({ session, auth_token: token }),
res,
);
expect(res.body).toEqual({ success: true });
expect(warn).toHaveBeenCalled();
const logged = warn.mock.calls
.flat()
.map((arg) => inspect(arg, { depth: 10 }))
.join(' ');
expect(logged).not.toContain(token);
} finally {
failingSet.mockRestore();
warn.mockRestore();
}
});
it('answers 400 when auth_token is not a string', async () => {
await expect(
controller.loginSet(
makeReq({ session: uuidv4(), auth_token: { nested: true } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
});
// -- Batch grant / revoke + cross-app data grants -----------------------
@@ -11658,100 +11093,3 @@ describe('AuthController — a seat address nobody confirmed', () => {
expect(await emailOf(seat.id)).toBeNull();
});
});
describe('AuthController account mail streams', () => {
const spySend = () =>
vi.spyOn(server.clients.email, 'send').mockResolvedValue(null);
it('sends password recovery on the auth stream', async () => {
const { user } = await makeUserAndActor();
const send = spySend();
try {
await controller.handleSendPassRecoveryEmail(
makeReq({ email: user.email! }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
user.email,
'email_password_recovery',
expect.anything(),
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
it('sends the password change notice on the auth stream', async () => {
const { user, actor } = await makeUserAndActor();
const req = makeReq({ new_pass: 'a-fresh-password-456' }, { actor });
(req as unknown as { userProtected: unknown }).userProtected = { user };
const send = spySend();
try {
await controller.handleChangePassword(req, makeRes());
expect(send).toHaveBeenCalledWith(
user.email,
'password_change_notification',
expect.anything(),
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
it('sends both change-email messages on the auth stream', async () => {
const { user, actor } = await makeUserAndActor();
const newEmail = `cs_${uniq()}@test.local`;
const send = spySend();
const sendRaw = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
try {
await controller.handleChangeEmail(
makeReq({ new_email: newEmail }, { actor }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
newEmail,
'email_verification_link',
expect.anything(),
{ stream: 'auth' },
);
expect(sendRaw).toHaveBeenCalledWith(
expect.objectContaining({ to: user.email, stream: 'auth' }),
);
} finally {
send.mockRestore();
sendRaw.mockRestore();
}
});
it('sends the 2FA enable and disable notices on the auth stream', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
const send = spySend();
try {
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(user.username, secret) });
expect(send).toHaveBeenCalledWith(
user.email,
'enabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
await controller.handleDisable2fa(
makeReq({ code: liveTotp(user.username, secret) }, { actor }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
user.email,
'disabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
});
+75 -174
View File
@@ -71,7 +71,6 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
import { parsePhone } from '../../util/phone.js';
import { deleteIfEqual } from '../../util/redisLock.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
@@ -144,12 +143,6 @@ const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
// Long enough to span the gap between one `/login/wait` and the client's next.
const LOGIN_RELAY_TTL_SECONDS = 120;
// Keyed by app too, so a token for one app never replaces another app's.
const loginRelayKey = (session: string, appUid: string) =>
`login:relay:${session}:${appUid}`;
// -- Post-login route limits -----------------------------------------
//
// The credential legs above (login, signup, recovery, confirmation) each
@@ -352,43 +345,36 @@ export class AuthController extends PuterController {
const expectedAppUid =
await this.services.auth.appUidFromOrigin(reqOrigin);
const relayKey = loginRelayKey(session, expectedAppUid);
const { resolve, promise } = Promise.withResolvers<string>();
const { resolve, promise } = Promise.withResolvers<void>();
let token: string | null = null;
const listener = (_key: string, value: { authtoken: string }) => {
token = value.authtoken;
resolve();
};
this.clients.event.on(`pubsub.login.${session}`, listener);
const timeout = new Promise<void>((resolve) =>
setTimeout(resolve, 10000),
);
await Promise.race([promise, timeout]);
this.clients.event.off(`pubsub.login.${session}`, listener);
if (!token) {
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
}
// Audience check. The postMessage hand-off this relay stands in for
// is origin-bound for free — it posts with `targetOrigin`, so a page
// can only ever receive a token minted for *itself*. Delivering
// server-side dropped that binding; this restores it. Any other token
// is passed over and the wait runs on, so a caller learns only that
// nothing arrived for them.
const listener = (_key: string, value: { authtoken: string }) => {
if (this.#relayedTokenAppUid(value.authtoken) === expectedAppUid) {
resolve(value.authtoken);
}
};
this.clients.event.on(`pubsub.login.${session}`, listener);
let token: string | null;
try {
// Read after subscribing, so a token set before this wait is still delivered.
token = await this.#takeStoredLoginToken(relayKey, expectedAppUid);
if (!token) {
const timeout = new Promise<null>((resolve) =>
setTimeout(resolve, 10000, null),
);
token = await Promise.race([promise, timeout]);
// A wait whose client went away leaves the copy for the next one.
if (token && !req.socket?.destroyed) {
await deleteIfEqual(
this.clients.redis,
relayKey,
token,
).catch(() => false);
}
}
} finally {
this.clients.event.off(`pubsub.login.${session}`, listener);
}
if (!token) {
// server-side dropped that binding; this restores it. Without it a
// popup talked into minting for app X (see `trustsOpenerOriginParam`
// in the GUI) hands X's token to whoever holds the session id.
if (!this.#tokenIsForApp(token, expectedAppUid)) {
// Deliberately the same 408 the no-token path returns: a caller
// learns only that nothing arrived for them, not that a token
// for a different app went past.
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
@@ -400,46 +386,24 @@ export class AuthController extends PuterController {
}
/**
* Take the copy `/login/set` kept for this app, once. Null when there is
* none, another wait took it, or Redis fails; the wait then just listens.
* Whether a relayed token is an app-under-user token minted for
* `expectedAppUid`. Verifies the signature — an unverified decode would let
* a caller relay a token whose claims it wrote itself.
*/
async #takeStoredLoginToken(
relayKey: string,
expectedAppUid: string,
): Promise<string | null> {
try {
const kept = await this.clients.redis.get(relayKey);
if (!kept || this.#relayedTokenAppUid(kept) !== expectedAppUid) {
return null;
}
const took = await deleteIfEqual(
this.clients.redis,
relayKey,
kept,
);
return took ? kept : null;
} catch {
return null;
}
}
/**
* The app a relayed app-under-user token was minted for, or null. Verifies
* the signature — an unverified decode would let a caller relay a token
* whose claims it wrote itself.
*/
#relayedTokenAppUid(token: string): string | null {
#tokenIsForApp(token: string, expectedAppUid: string): boolean {
try {
const payload = this.services.token.verify<{
type?: string;
app_uid?: string;
}>('auth', token);
return payload.type === 'app-under-user' && payload.app_uid
? payload.app_uid
: null;
return (
payload.type === 'app-under-user' &&
!!payload.app_uid &&
payload.app_uid === expectedAppUid
);
} catch {
// Malformed, expired, or signed with a key we don't hold.
return null;
return false;
}
}
@Post('/login/set', {
@@ -454,34 +418,12 @@ export class AuthController extends PuterController {
})
async loginSet(req: Request, res: Response) {
const { session, auth_token } = req.body ?? {};
if (
!session ||
typeof auth_token !== 'string' ||
!auth_token ||
!validateUuid(session)
) {
if (!session || !auth_token || !validateUuid(session)) {
throw new HttpError(400, 'session and auth_token are required.', {
legacyCode: 'bad_request',
});
}
// Only a token a wait could deliver is kept: one this server signed for an app.
const appUid = this.#relayedTokenAppUid(auth_token);
if (appUid) {
try {
await this.clients.redis.set(
loginRelayKey(session, appUid),
auth_token,
'EX',
LOGIN_RELAY_TTL_SECONDS,
);
} catch (e) {
console.warn(
'[login-set] storing the relayed token failed:',
e instanceof Error ? e.message : String(e),
);
}
}
this.clients.event.emit(
`pubsub.login.${session}`,
{
@@ -940,19 +882,14 @@ export class AuthController extends PuterController {
if (!bonusCode) throw bonusCodeInvalidError();
}
const { user, confirmationEmailSent } =
await this.services.signup.signup({
req,
body,
isTemp: is_temp,
fingerprint,
bonusCode,
});
await this.#completeLogin(req, res, user, undefined, {
...(confirmationEmailSent !== undefined
? { confirmation_email_sent: confirmationEmailSent }
: {}),
const user = await this.services.signup.signup({
req,
body,
isTemp: is_temp,
fingerprint,
bonusCode,
});
await this.#completeLogin(req, res, user);
}
/**
@@ -1091,12 +1028,10 @@ export class AuthController extends PuterController {
email_confirm_code: code,
});
const sent = await this.services.signup.sendConfirmationEmail(
user,
'resend',
{ code },
);
res.json({ sent });
await this.services.signup.sendConfirmationEmail(user, 'resend', {
code,
});
res.json({});
}
@Post('/confirm-email', {
@@ -1510,7 +1445,6 @@ export class AuthController extends PuterController {
ip,
device_id: req.deviceFingerprint ?? undefined,
user_agent: userAgent,
tls_fingerprint: req.tlsFingerprint,
dispatch_id: dispatchId,
},
);
@@ -2085,7 +2019,6 @@ export class AuthController extends PuterController {
user.email,
'email_password_recovery',
{ link },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[send-pass-recovery-email] send failed:', e);
@@ -2349,7 +2282,6 @@ export class AuthController extends PuterController {
{
username: user.username,
},
{ stream: 'auth' },
);
} catch (e) {
console.warn('[change-password] notification send failed:', e);
@@ -2575,7 +2507,6 @@ export class AuthController extends PuterController {
new_email,
'email_verification_link',
{ link },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[change-email] new-address email failed:', e);
@@ -2583,8 +2514,15 @@ export class AuthController extends PuterController {
// Notify the old address too
if (user.email) {
try {
await this.clients.email.sendRaw({
stream: 'auth',
await (
this.clients.email as unknown as {
sendRaw: (opts: {
to: string;
subject: string;
text: string;
}) => Promise<unknown>;
}
).sendRaw({
to: user.email,
subject: 'Your Puter email change was requested',
text: `A change to ${new_email} was requested on your account. If this wasn't you, please contact support.`,
@@ -2873,11 +2811,18 @@ export class AuthController extends PuterController {
username,
});
await this.services.signup.sendConfirmationEmail(
{ ...user, username, email },
'save-account',
{ code: email_confirm_code },
);
// Send confirmation email
if (this.clients.email) {
try {
await this.clients.email.send(
email,
'email_verification_code',
{ code: email_confirm_code },
);
} catch (e) {
console.warn('[save-account] confirmation email failed:', e);
}
}
try {
this.clients.event?.emit(
@@ -4327,12 +4272,9 @@ export class AuthController extends PuterController {
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
user.email,
'enabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
await this.clients.email.send(user.email, 'enabled_2fa', {
username: user.username,
});
} catch (e) {
console.warn('[configure-2fa] email send failed:', e);
}
@@ -4406,12 +4348,9 @@ export class AuthController extends PuterController {
if (this.clients.email && user.email) {
try {
await this.clients.email.send(
user.email,
'disabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
await this.clients.email.send(user.email, 'disabled_2fa', {
username: user.username,
});
} catch (e) {
console.warn('[disable-2fa] email send failed:', e);
}
@@ -4487,34 +4426,6 @@ export class AuthController extends PuterController {
res.json({ token: guiToken });
}
@Post('/auth/first-party-session', {
subdomain: 'api',
rateLimit: SESSION_LIMIT,
// Hands out a session, so only the GUI's own page script may ask, and
// only as the user's sign-in itself.
guiOriginOnly: true,
requireUserActor: true,
})
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
const { origin } = req.body ?? {};
if (typeof origin !== 'string' || !origin) {
throw new HttpError(400, 'Missing `origin`', {
legacyCode: 'bad_request',
});
}
const token = await this.services.auth.createFirstPartySessionToken(
req.actor!,
origin,
{
ip: req.ip || req.socket?.remoteAddress,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
host: req.headers?.host,
},
);
res.json({ token });
}
@Get('/session/sync-cookie', {
rateLimit: SESSION_LIMIT,
// Installs the session cookie. Only page script on our own origin
@@ -4539,14 +4450,6 @@ export class AuthController extends PuterController {
res.status(400).end();
return;
}
// A session handed to a first-party page never gets a cookie.
if (req.actor.session.parentUid) {
throw new HttpError(
403,
'This session cannot install a session cookie',
{ legacyCode: 'forbidden' },
);
}
// As in `/get-gui-token`: the actor is the user's row as auth read it.
const sessionToken = this.services.auth.createSessionTokenForSession(
req.actor.user as UserRow,
@@ -4863,7 +4766,6 @@ export class AuthController extends PuterController {
res: Response,
user: UserRow,
oidcContinuation?: unknown,
extra: { confirmation_email_sent?: boolean } = {},
): Promise<void> {
const redirectUrl =
oidcContinuation === undefined
@@ -4898,7 +4800,6 @@ export class AuthController extends PuterController {
token: gui_token,
user: details,
...(redirectUrl ? { redirectUrl } : {}),
...extra,
});
}
}
@@ -1,282 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { IConfig } from '../../types.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PAGE = 'https://desk.first-party.test';
// Seeded with godmode on.
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
/**
* One of the deployment's own pages, served apart from the GUI, signs in with a
* `web` session of its own under the user's sign-in.
*/
describe('first-party page sessions', () => {
let env: PuterTestEnv;
let userId = 0;
beforeAll(async () => {
env = await setupPuterTestEnv({
first_party_session_origins: [`${PAGE}/`],
} as IConfig);
const row = await env.server.stores.user.getByUsername(
env.users.user.username,
);
userId = row!.id;
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
headers: Record<string, string> = {},
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** A sign-in of its own, so revoking it leaves the shared one up. */
const signIn = async () => {
const user = await env.server.stores.user.getById(userId);
const { gui_token, session } =
await env.server.services.auth.createSessionToken(user!);
return { token: gui_token, uuid: session.uuid as string };
};
const mint = async (token: string, origin = PAGE) => {
const res = await call('POST', '/auth/first-party-session', token, {
origin,
});
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
const actorOf = async (token: string) =>
(await env.server.services.auth.authenticate(token)).actor;
const sessions = async (token: string) => {
const res = await call('GET', '/auth/list-sessions', token);
expect(res.status).toBe(200);
return (await res.json()) as Array<Record<string, unknown>>;
};
it('hands the page a session of its own under the sign-in', async () => {
const root = await signIn();
const token = await mint(root.token);
const actor = await actorOf(token);
expect(actor?.app ?? null).toBeNull();
expect(actor?.accessToken ?? null).toBeNull();
expect(actor?.session).toMatchObject({
kind: 'web',
parentUid: root.uuid,
});
expect(actor?.session?.uid).not.toBe(root.uuid);
const row = (await sessions(root.token)).find(
(s) => s.uuid === actor!.session!.uid,
);
expect(row).toMatchObject({
kind: 'web',
parent_session_id: root.uuid,
first_party_origin: PAGE,
current: false,
});
});
it('reuses the session on a second ask', async () => {
const root = await signIn();
const first = await actorOf(await mint(root.token));
const second = await actorOf(await mint(root.token));
expect(second?.session?.uid).toBe(first?.session?.uid);
});
it('acts as the account: launches apps, godmode ones included', async () => {
const root = await signIn();
const token = await mint(root.token);
const res = await call('POST', '/auth/get-user-app-token', token, {
app_uid: DEV_CENTER,
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ godmode: true });
});
it('refuses an origin that is not listed', async () => {
const root = await signIn();
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{
origin: 'https://elsewhere.test',
},
);
expect(res.status).toBe(403);
});
it('refuses a page on another origin, the listed page included', async () => {
const root = await signIn();
for (const origin of [PAGE, 'https://elsewhere.test']) {
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{ origin: PAGE },
{ origin },
);
expect(res.status).toBe(403);
}
});
it('refuses app tokens, access tokens and a first-party session', async () => {
const root = await signIn();
const page = await mint(root.token);
const app = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
origin: 'https://some-app.test',
})
).json()) as { token: string };
const godmode = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
for (const token of [page, app.token, godmode.token]) {
const res = await call('POST', '/auth/first-party-session', token, {
origin: PAGE,
});
expect(res.status).toBe(403);
}
});
it('never gets a session cookie', async () => {
const root = await signIn();
const page = await mint(root.token);
const syncCookie = (token: string) =>
fetch(new URL('/session/sync-cookie', env.origin), {
headers: { authorization: `Bearer ${token}` },
});
expect((await syncCookie(root.token)).status).toBe(204);
const res = await syncCookie(page);
expect(res.status).toBe(403);
expect(res.headers.get('set-cookie')).toBeNull();
});
it('ends with the sign-in it was handed out under', async () => {
const root = await signIn();
const page = await mint(root.token);
expect(await actorOf(page)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
const result = await env.server.services.auth.authenticate(page);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');
});
it('takes the tokens minted under it along when its sign-in ends', async () => {
const root = await signIn();
const page = await mint(root.token);
const godmode = (await (
await call('POST', '/auth/get-user-app-token', page, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
const scoped = (await (
await call('POST', '/auth/create-access-token', godmode.token, {
permissions: ['service:foo:ii:read'],
expiresIn: '7d',
})
).json()) as { token: string };
expect(await actorOf(scoped.token)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
for (const token of [godmode.token, scoped.token]) {
expect(
(await env.server.services.auth.authenticate(token)).actor,
).toBeUndefined();
}
});
it('is revoked on its own without ending the sign-in', async () => {
const root = await signIn();
const page = await mint(root.token);
const pageUuid = (await actorOf(page))!.session!.uid;
await env.server.services.auth.revokeSession(pageUuid);
expect(
(await env.server.services.auth.authenticate(page)).actor,
).toBeUndefined();
expect(await actorOf(root.token)).toBeDefined();
});
it('keeps the whole current sign-in when revoking all others', async () => {
const root = await signIn();
const page = await mint(root.token);
const other = await signIn();
const otherPage = await mint(other.token);
// Asked from the page: its sign-in stays, and so does the page.
await env.server.services.auth.revokeAllSessions(
(await actorOf(page))!,
);
expect(await actorOf(page)).toBeDefined();
expect(await actorOf(root.token)).toBeDefined();
expect(
(await env.server.services.auth.authenticate(other.token)).actor,
).toBeUndefined();
expect(
(await env.server.services.auth.authenticate(otherPage)).actor,
).toBeUndefined();
// Asked from the sign-in: the page under it stays too.
await env.server.services.auth.revokeAllSessions(
(await actorOf(root.token))!,
);
expect(await actorOf(page)).toBeDefined();
});
it('is not counted as a sign-in', async () => {
const root = await signIn();
const before = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
await mint(root.token);
const after = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
expect(after.length).toBe(before.length);
});
});
@@ -0,0 +1,165 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HttpError } from '../../core/http/HttpError.js';
import { PuterController } from '../types.js';
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Folder view preference routes: layout and sort_by/sort_order, stored on the
* folder's fsentries row.
*/
/**
* Desktop preference writes — layout, sort order. Each persists on every call,
* and the GUI fires them on direct user action, so a per-minute ceiling well
* above human speed is enough to catch a stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
limit: 120,
window: 60_000,
key: 'user',
};
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Folder layout -------------------------------------------
router.post(
'/set_layout',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, layout } = req.body ?? {};
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
throw new HttpError(
400,
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{ layout },
);
res.json({});
},
);
// -- Folder sort ---------------------------------------------
router.post(
'/set_sort_by',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, sort_by, sort_order } =
req.body ?? {};
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
throw new HttpError(
400,
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
const resolvedOrder = sort_order ?? 'asc';
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
throw new HttpError(
400,
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{
sort_by,
sort_order: resolvedOrder,
},
);
res.json({});
},
);
}
// -- Helpers ------------------------------------------------------
/**
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
* path) or `item_path` (path lookups in FSEntryStore now have a
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
* works for old accounts too).
*
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
* against bad paths — the previous "drop user_id entirely" theory turned
* out to be wrong (the actual legacy issue was NULL paths, not user_id
* drift), so this filter doesn't lock out old accounts in practice.
*/
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
if (!item_uid && !item_path) {
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
legacyCode: 'bad_request',
});
}
const entry = item_uid
? await this.stores.fsEntry.getEntryByUuid(item_uid)
: await this.stores.fsEntry.getEntryByPath(item_path);
if (!entry) {
throw new HttpError(404, 'Item not found', {
legacyCode: 'not_found',
});
}
const actorUserId = actor?.user?.id;
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
throw new HttpError(403, 'Not allowed to update this item', {
legacyCode: 'forbidden',
});
}
const keys = Object.keys(patch);
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
const values = keys.map((k) => patch[k]);
await this.clients.db.write(
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
[...values, entry.id],
);
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
}
onServerStart() {}
onServerPrepareShutdown() {}
onServerShutdown() {}
}
@@ -0,0 +1,381 @@
/*
* 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, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
// redis) and re-registers DesktopController's inline lambda routes
// onto a fresh PuterRouter so each handler is reachable. Each test
// makes its own user via `makeUser` and exercises the live controller
// against the real wired stores (user, fsEntry) and DB client.
let server: PuterServer;
let router: PuterRouter;
beforeAll(async () => {
server = await setupTestServer();
router = new PuterRouter();
server.controllers.desktop.registerRoutes(router);
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
interface CapturedResponse {
statusCode: number;
body: unknown;
}
const makeReq = (init: {
body?: unknown;
actor?: Actor;
}): Request => {
return {
body: init.body ?? {},
query: {},
headers: {},
actor: init.actor,
} as unknown as Request;
};
const makeRes = () => {
const captured: CapturedResponse = { statusCode: 200, body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn((code: number) => {
captured.statusCode = code;
return res;
}),
setHeader: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
const findHandler = (method: string, path: string): RequestHandler => {
const route = router.routes.find(
(r) => r.method === method && r.path === path,
);
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
return route.handler;
};
const callRoute = async (
method: string,
path: string,
req: Request,
res: Response,
) => {
const handler = findHandler(method, path);
await handler(req, res, () => {
throw new Error('handler called next() unexpectedly');
});
};
describe('DesktopController routes', () => {
it('has no desktop background route', () => {
expect(
router.routes.some((r) => r.path === '/set-desktop-bg'),
).toBe(false);
});
it('has no taskbar items route', () => {
expect(
router.routes.some((r) => r.path === '/update-taskbar-items'),
).toBe(false);
});
});
// ── /set_layout ─────────────────────────────────────────────────────
describe('DesktopController POST /set_layout', () => {
it('persists the new layout on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
expect(documents).not.toBeNull();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'icons' },
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.layout).toBe('icons');
});
it('resolves by item_path when item_uid is omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Pictures`;
const { res } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_path: path, layout: 'list' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
expect(refreshed?.layout).toBe('list');
});
it('throws 400 for an unknown layout value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'gallery' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({ body: { layout: 'icons' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 404 when the fsentry cannot be resolved', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: {
item_uid: '00000000-0000-0000-0000-000000000000',
layout: 'icons',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 404 });
});
it("throws 403 when the fsentry belongs to another user", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const ownerUsername = owner.actor.user!.username!;
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
`/${ownerUsername}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
actor: intruder.actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
// Owner's entry remains untouched.
const refreshed = await server.stores.fsEntry.getEntryByUuid(
ownerEntry!.uuid,
);
expect(refreshed?.layout).toBeFalsy();
});
});
// ── /set_sort_by ────────────────────────────────────────────────────
describe('DesktopController POST /set_sort_by', () => {
it('persists sort_by + sort_order on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: documents!.uuid,
sort_by: 'name',
sort_order: 'desc',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.sortBy).toBe('name');
expect(refreshed?.sortOrder).toBe('desc');
});
it('defaults sort_order to "asc" when omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const pictures = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures`,
);
const { res } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByUuid(
pictures!.uuid,
);
expect(refreshed?.sortBy).toBe('modified');
expect(refreshed?.sortOrder).toBe('asc');
});
it('throws 400 for an unknown sort_by value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: docs!.uuid, sort_by: 'random' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 for an invalid sort_order', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: docs!.uuid,
sort_by: 'name',
sort_order: 'sideways',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
+1 -2
View File
@@ -18,7 +18,6 @@
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
@@ -47,7 +46,7 @@ export class CallSlot {
this.#maybeRelease();
};
res.once('finish', responded);
onResponseClose(res, responded);
res.once('close', responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
+1 -3
View File
@@ -1850,9 +1850,7 @@ export class FSController extends PuterController {
}
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
const existing = await this.stores.fsEntry.getEntryByPath(targetPath, {
foldedNames: true,
});
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
if (!existing) return;
const parent = pathPosix.dirname(targetPath);
await this.#assertAccess(
@@ -168,27 +168,6 @@ describe('legacy file downloads over HTTP', () => {
},
);
it('GET /token-read types a file by its name, and one with no known extension as a byte stream', async () => {
const typed = await requestFor(
'/token-read',
await writeFile('notes.txt', Buffer.from('hello')),
);
expect(typed.headers.get('content-type')).toBe(
'text/plain; charset=utf-8',
);
for (const name of ['README', 'archive.unknownext']) {
const response = await requestFor(
'/token-read',
await writeFile(name, Buffer.from('hello')),
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe(
'application/octet-stream',
);
}
});
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
for (const query of ['byte_count=512', 'offset=0']) {
@@ -765,10 +765,8 @@ export class LegacyFSController extends PuterController {
normalizedTarget,
);
if (dedupeName) {
const existing = await this.stores.fsEntry.getEntryByPath(
normalizedTarget,
{ foldedNames: true },
);
const existing =
await this.stores.fsEntry.getEntryByPath(normalizedTarget);
if (existing) {
const parent = pathPosix.dirname(normalizedTarget);
await assertAccess(
@@ -1322,7 +1320,7 @@ export class LegacyFSController extends PuterController {
if (options.realMime) {
res.setHeader(
'Content-Type',
contentTypeFromMime(entry.name) || 'application/octet-stream',
contentTypeFromMime(entry.name) as string,
);
} else {
res.setHeader('Content-Type', 'application/octet-stream');
@@ -277,124 +277,4 @@ describe('writes inside a folder shared with write', () => {
expect(JSON.stringify(res.json)).not.toContain(s.realDir);
}
}, 180_000);
describe('a file the recipient opened with an app', () => {
const makeApp = (ownerUserId: number) =>
(
env.server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `sfw-app-${Math.random().toString(36).slice(2, 9)}`,
title: 'Shared folder app',
index_url: `https://sfw-${Math.random().toString(36).slice(2, 9)}.test/`,
},
{ ownerUserId },
);
/** Opens `note.txt` with a new app, as a launch does. */
const openWithApp = async (
s: Awaited<ReturnType<typeof shareAFolder>>,
) => {
const recipientId = (await env.server.stores.user.getByUsername(
s.recipient.username,
))!.id;
const app = await makeApp(recipientId);
const signed = await post('/sign', s.recipient.token, {
items: [{ uid: s.noteUid, action: 'write' }],
app_uid: app.uid,
});
expect(signed.status).toBe(200);
return { app, appToken: signed.json!.token as string };
};
const read = async (token: string, query: Record<string, string>) => {
const res = await fetch(
new URL(`/read?${new URLSearchParams(query)}`, env.apiOrigin),
{ headers: { authorization: `Bearer ${token}` } },
);
return { status: res.status, text: await res.text() };
};
const write = (token: string, path: string, content: string) =>
post('/fs/write', token, {
fileMetadata: {
path,
size: content.length,
contentType: 'text/plain',
overwrite: true,
},
fileContent: content,
});
it('reads and saves it by path and uid', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const masked = `${s.maskedDir}/note.txt`;
const real = `${s.realDir}/note.txt`;
expect((await write(appToken, masked, 'one')).status).toBe(200);
expect(await read(appToken, { uid: s.noteUid })).toEqual({
status: 200,
text: 'one',
});
expect((await write(appToken, real, 'two')).status).toBe(200);
expect(await read(appToken, { file: real })).toEqual({
status: 200,
text: 'two',
});
expect(
(await post('/stat', appToken, { uid: s.noteUid })).status,
).toBe(200);
}, 180_000);
it('only reads it once the share drops to read', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const dir = await post('/stat', s.owner.token, { path: s.realDir });
const reshared = await post('/share', s.owner.token, {
recipients: [s.recipient.username],
items: [{ uid: dir.json!.uid }],
mode: 'read',
});
expect(reshared.status).toBe(200);
expect((await read(appToken, { uid: s.noteUid })).status).toBe(200);
expect(
(await write(appToken, `${s.maskedDir}/note.txt`, 'x')).status,
).toBe(403);
}, 180_000);
it('gives nothing to another app of the recipient', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const recipientId = (await env.server.stores.user.getByUsername(
s.recipient.username,
))!.id;
const other = await makeApp(recipientId);
const minted = await post(
'/auth/get-user-app-token',
s.recipient.token,
{ app_uid: other.uid },
);
expect(minted.status).toBe(200);
const otherToken = minted.json!.token as string;
// The same requests the opened app gets through.
const real = `${s.realDir}/note.txt`;
expect((await read(appToken, { uid: s.noteUid })).status).toBe(200);
expect((await write(appToken, real, 'one')).status).toBe(200);
expect((await read(otherToken, { uid: s.noteUid })).status).toBe(
404,
);
expect((await write(otherToken, real, 'x')).status).toBe(404);
expect(await read(appToken, { uid: s.noteUid })).toEqual({
status: 200,
text: 'one',
});
}, 180_000);
});
});
+2
View File
@@ -21,6 +21,7 @@ import { AppController } from './apps/AppController.js';
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
import { AuthController } from './auth/AuthController.js';
import { BroadcastController } from './broadcast/BroadcastController.js';
import { DesktopController } from './desktop/DesktopController.js';
import { DriverController } from './drivers/DriverController.js';
import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
@@ -48,6 +49,7 @@ export const puterControllers = {
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
hosting: HostingController,
system: SystemController,
fs: FSController,
@@ -44,7 +44,7 @@ import {
} from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { ChatCompletionDriver } from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { PuterServer } from '../../server.js';
import { inRequestScope, setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
@@ -49,8 +49,8 @@ import type { RouteOptions } from '../../core/http/index.js';
import {
COUNT_TOKENS,
type ChatCompletionDriver,
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/ai/shared/aiLimits.js';
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterServer } from '../../server.js';
import { inRequestScope, setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
@@ -30,28 +30,25 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
import {
COUNT_TOKENS,
type ChatCompletionDriver,
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
import type {
IChatCompleteResult,
IChatMessageResult,
IChatModel,
ICompleteArguments,
UsageDetails,
} from '../../drivers/ai/interfaces/chat/types.js';
} from '../../drivers/ai-chat/types.js';
import {
outputFormatFromResponseFormat,
rejectStatefulResponsesFields,
toolChoiceFromWire,
} from '../../drivers/ai/protocols/openaiCompatible/openaiParams.js';
} from '../../drivers/ai-chat/utils/openaiParams.js';
import {
promoteStopForToolCalls,
toFinishReason,
} from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
} from '../../drivers/ai-chat/utils/stopReason.js';
import { isDriverStreamResult } from '../../drivers/meta.js';
import {
AI_CONCURRENT,
AI_RATE_LIMIT,
} from '../../drivers/ai/shared/aiLimits.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterController } from '../types.js';
import { parseAnthropicRequest, toAnthropicMessage } from './anthropicWire.js';
import { pipeNdjsonStream } from './ndjsonStream.js';
@@ -18,7 +18,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { IChatMessageResult } from '../../drivers/ai/interfaces/chat/types.js';
import type { IChatMessageResult } from '../../drivers/ai-chat/types.js';
import {
anthropicUsage,
parseAnthropicRequest,
@@ -32,7 +32,7 @@
import type { IncomingHttpHeaders } from 'node:http';
import { HttpError } from '../../core/http/HttpError.js';
import { sanitizeCacheControl } from '../../drivers/ai/providers/claude/chat/anthropicPolicy.js';
import { sanitizeCacheControl } from '../../drivers/ai-chat/providers/claude/anthropicPolicy.js';
import type {
ICompleteArguments,
IChatMessageResult,
@@ -42,9 +42,9 @@ import type {
ThinkingConfig,
ToolChoice,
UsageDetails,
} from '../../drivers/ai/interfaces/chat/types.js';
import { needsOpenAICoercion } from '../../drivers/ai/interfaces/chat/utils/normalizeToOpenAI.js';
import { fromFinishReason } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
} from '../../drivers/ai-chat/types.js';
import { needsOpenAICoercion } from '../../drivers/ai-chat/utils/normalizeToOpenAI.js';
import { fromFinishReason } from '../../drivers/ai-chat/utils/stopReason.js';
const toStringOrEmpty = (v: unknown): string =>
typeof v === 'string' ? v : '';
+5 -6
View File
@@ -20,15 +20,14 @@
/**
* SSE transport (`startSse`) plus the Anthropic `/v1/messages` streaming writer
* (`AnthropicSseWriter`), which turns the driver's NDJSON chunk protocol
* (`drivers/ai/interfaces/chat/utils/Streaming.js`) into the exact Anthropic
* event sequence: `message_start` → `content_block_start/delta/stop`* →
* (`drivers/ai-chat/utils/Streaming.js`) into the exact Anthropic event
* sequence: `message_start` → `content_block_start/delta/stop`* →
* `message_delta` → `message_stop`.
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
import type { UsageDetails } from '../../drivers/ai/interfaces/chat/types.js';
import { promoteStopForToolCalls } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
import type { UsageDetails } from '../../drivers/ai-chat/types.js';
import { promoteStopForToolCalls } from '../../drivers/ai-chat/utils/stopReason.js';
import { anthropicUsage } from './anthropicWire.js';
// -- SSE transport ------------------------------------------------------------
@@ -80,7 +79,7 @@ export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
timer.unref?.();
}
onResponseClose(res, () => {
res.once('close', () => {
ended = true;
if (timer) clearInterval(timer);
});
@@ -301,7 +301,6 @@ export class SystemController extends PuterController {
if (this.clients.email && req.actor.user?.email) {
try {
await this.clients.email.sendRaw({
stream: 'notify',
to: supportEmail,
replyTo: req.actor.user.email,
subject: `Contact from ${req.actor.user.username}`,
@@ -547,7 +547,6 @@ describe('SystemController POST /contactUs — attachments', () => {
expect(sendRaw).toHaveBeenCalledTimes(1);
const sent = sendRaw.mock.calls[0][0] as {
stream: string;
text: string;
attachments: Array<{
filename: string;
@@ -556,7 +555,6 @@ describe('SystemController POST /contactUs — attachments', () => {
contentDisposition: string;
}>;
};
expect(sent.stream).toBe('notify');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('payload.png');
expect(sent.attachments[0].contentType).toBe('image/png');
@@ -1,210 +0,0 @@
/*
* 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 { GetObjectCommand } from '@aws-sdk/client-s3';
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
TEST_USER_CREDENTIALS,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/** File downloads over WebDAV: the bytes, ranges, and either end failing. */
describe('WebDAV GET over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 60_000);
afterAll(async () => {
await env?.shutdown();
});
const dav = (path: string, init: RequestInit = {}) => {
const port = new URL(env.apiOrigin).port;
const auth = Buffer.from(
`${TEST_USER_CREDENTIALS.username}:${TEST_USER_CREDENTIALS.password}`,
).toString('base64');
return fetch(`http://dav.puter.localhost:${port}${encodeURI(path)}`, {
...init,
headers: {
Authorization: `Basic ${auth}`,
// Uncompressed, so the bytes on the wire are the stored ones.
'Accept-Encoding': 'identity',
...(init.headers as Record<string, string> | undefined),
},
});
};
const writeFile = async (name: string, content: Buffer) => {
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const path = `/${username}/Documents/${name}`;
const { fsEntry } = await env.server.services.fs.write(user!.id, {
fileMetadata: {
path,
size: content.byteLength,
contentType: 'application/octet-stream',
},
fileContent: content,
});
return { path, uuid: fsEntry.uuid };
};
it('returns the stored bytes, whole or as a range', async () => {
const content = randomBytes(128 * 1024 + 3);
const file = await writeFile('dav-get.bin', content);
const whole = await dav(file.path);
expect(whole.status).toBe(200);
expect(whole.headers.get('content-length')).toBe(
String(content.byteLength),
);
expect(Buffer.from(await whole.arrayBuffer())).toEqual(content);
const range = await dav(file.path, {
headers: { Range: 'bytes=10-19' },
});
expect(range.status).toBe(206);
expect(range.headers.get('content-range')).toBe(
`bytes 10-19/${content.byteLength}`,
);
expect(Buffer.from(await range.arrayBuffer())).toEqual(
content.subarray(10, 20),
);
});
/**
* Storage hands over one chunk of `file` and then goes quiet, as a large
* object does while the client is still reading.
*/
const stallStorage = (file: { uuid: string }) => {
const s3 = env.server.stores.s3Object;
const client = env.server.clients.s3.get(s3.resolveRegion(null));
const send = client.send.bind(client);
const bodies: Readable[] = [];
const spy = vi.spyOn(client, 'send').mockImplementation(((
command: unknown,
) => {
if (
!(command instanceof GetObjectCommand) ||
command.input.Key !== file.uuid
) {
return send(command as never);
}
let pushed = false;
const body = new Readable({
read() {
if (!pushed) {
pushed = true;
this.push(Buffer.alloc(16 * 1024, 1));
}
},
});
bodies.push(body);
return Promise.resolve({
Body: body,
ContentLength: 1024 * 1024,
ContentType: 'application/octet-stream',
});
}) as never);
return { bodies, restore: () => spy.mockRestore() };
};
const expectFullDownload = async (path: string) => {
const response = await dav(path);
expect(response.status).toBe(200);
expect((await response.arrayBuffer()).byteLength).toBe(1024 * 1024);
};
it('releases the storage stream when the client aborts', async () => {
const file = await writeFile(
'dav-abort.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const abort = new AbortController();
const response = await dav(file.path, { signal: abort.signal });
expect(response.status).toBe(200);
await response.body!.getReader().read();
abort.abort();
expect(storage.bodies).toHaveLength(1);
await vi.waitFor(() =>
expect(storage.bodies[0]!.destroyed).toBe(true),
);
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('cuts the download off when storage fails mid-stream', async () => {
const file = await writeFile(
'dav-reset.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
await reader.read();
storage.bodies[0]!.destroy(new Error('upstream reset'));
const outcome = await (async () => {
try {
while (!(await reader.read()).done);
return 'ended';
} catch {
return 'cut off';
}
})();
expect(outcome).toBe('cut off');
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('stays within the close listener budget per response', async () => {
const file = await writeFile('dav-listeners.bin', randomBytes(4096));
const tracker = trackResponseCloseListeners();
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak(
(req) => req.method === 'GET' && req.url === file.path,
);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
@@ -1365,7 +1365,6 @@ describe('WebDAVController verbs', () => {
callback();
},
});
sink.on('error', () => {});
sink.on('finish', () => {
captured.ended = true;
});
@@ -21,6 +21,7 @@ import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
@@ -550,7 +551,12 @@ export class WebDAVController extends PuterController {
'Content-Length': String(result.contentLength ?? 0),
});
}
await pipeToResponse(result.body, res);
try {
await pipeline(result.body, res);
} catch {
// Client disconnect or upstream stream error: pipeline already
// tore down both ends, so nothing is left dangling to crash on.
}
}
// -- PROPFIND ----------------------------------------------------
@@ -807,10 +813,7 @@ export class WebDAVController extends PuterController {
const userId = actor.user!.id as number;
await this.#assertCreate(actor, davPath);
// A name only the collation matches is taken too.
const existing = await this.stores.fsEntry.getEntryByPath(davPath, {
foldedNames: true,
});
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
if (existing)
throw new HttpError(405, 'Already exists', {
legacyCode: 'bad_request',
@@ -934,9 +937,7 @@ export class WebDAVController extends PuterController {
await this.#assertCreate(actor, destPath);
const overwrite = req.headers.overwrite !== 'F';
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
foldedNames: true,
});
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
if (destExists && !overwrite)
throw new HttpError(412, 'Destination exists and Overwrite=F', {
legacyCode: 'conflict',
@@ -984,9 +985,7 @@ export class WebDAVController extends PuterController {
await this.#assertCreate(actor, destPath);
const overwrite = req.headers.overwrite !== 'F';
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
foldedNames: true,
});
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
if (destExists && !overwrite)
throw new HttpError(412, 'Destination exists and Overwrite=F', {
legacyCode: 'conflict',
+1 -7
View File
@@ -76,14 +76,8 @@ export interface Actor {
/**
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
* `parentUid` is the sign-in a `web` session was handed out under, set only
* on a first-party page's session.
*/
session?: {
uid: string;
kind?: string | null;
parentUid?: string | null;
} | null;
session?: { uid: string; kind?: string | null } | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
+1 -2
View File
@@ -19,7 +19,6 @@
import type { Response } from 'express';
import { HttpError } from './HttpError.js';
import { onResponseClose } from './onResponseClose.js';
/**
* A signal that fires when the caller hangs up before its response is done, so
@@ -39,6 +38,6 @@ export const abortOnDisconnect = (res: Response): AbortSignal => {
}
};
if (res.destroyed) onClose();
else onResponseClose(res, onClose);
else res.once('close', onClose);
return abort.signal;
};
@@ -104,13 +104,6 @@ declare global {
*/
deviceFingerprint?: string;
/**
* The client's TLS fingerprint, read from the header named by
* `tlsFingerprintHeader`. Shared by every client on the same TLS
* stack, so it identifies software, not a person.
*/
tlsFingerprint?: string;
/**
* Parsed cookies, populated by the global `cookie-parser`
* middleware.
@@ -28,7 +28,6 @@ import {
} from '../../../services/metering/costs.js';
import type { UsageInput } from '../../../services/metering/types';
import '../expressAugmentation';
import { onResponseClose } from '../onResponseClose';
/**
* Subset of the metering service this middleware needs. Metering is optional
@@ -196,7 +195,7 @@ export const createEgressMeteringMiddleware = (
// 'close' rather than 'finish': it fires for a response that completed
// AND for one whose connection died mid-stream, and an aborted download
// still sent whatever it sent.
onResponseClose(res, () => {
res.once('close', () => {
try {
if (!isMeteredHost(req)) return;
const actor = resolveEgressActor(req);
@@ -23,11 +23,8 @@ import { createFingerprintMiddleware } from './fingerprint';
const VALID_FP = 'dev-fingerprint-abc123';
const run = (
reqPartial: Partial<Request> & Record<string, unknown>,
opts?: Parameters<typeof createFingerprintMiddleware>[0],
) => {
const middleware = createFingerprintMiddleware(opts);
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
const middleware = createFingerprintMiddleware();
const req = {
headers: {},
...reqPartial,
@@ -93,21 +90,4 @@ describe('createFingerprintMiddleware', () => {
const { req } = run({ ip: '203.0.113.1' });
expect(req.deviceFingerprint).toBeUndefined();
});
it('reads the TLS fingerprint from the configured header only', () => {
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
const headers = { 'x-tls-fp': ja4 };
expect(run({ headers }).req.tlsFingerprint).toBeUndefined();
expect(
run({ headers }, { tlsFingerprintHeader: 'X-TLS-FP' }).req
.tlsFingerprint,
).toBe(ja4);
expect(
run(
{ headers: { 'x-tls-fp': 'not a fingerprint!' } },
{ tlsFingerprintHeader: 'x-tls-fp' },
).req.tlsFingerprint,
).toBeUndefined();
});
});
@@ -33,20 +33,6 @@ const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
/** Header the GUI may send the device fingerprint on for non-signup requests. */
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
/** JA3 / JA4-style values; anything else is dropped. */
const TLS_FINGERPRINT_SHAPE = /^[A-Za-z0-9_]{10,64}$/;
function readTlsFingerprint(
req: { headers?: Record<string, unknown> },
header: string | undefined,
): string | undefined {
if (!header) return undefined;
const value = req.headers?.[header];
return typeof value === 'string' && TLS_FINGERPRINT_SHAPE.test(value)
? value
: undefined;
}
function readDeviceFingerprint(req: {
body?: unknown;
headers?: Record<string, unknown>;
@@ -79,22 +65,15 @@ function readDeviceFingerprint(req: {
* and spoofable, but stable per real device across IP rotation. The rate
* limiter's 'fingerprint' strategy appends it to the network hash so each
* device behind a shared network gets its own bucket.
* - `req.tlsFingerprint` — set only when `tlsFingerprintHeader` is configured and
* the request carries a well-shaped value in that header. It names the
* client's TLS stack, so every client on the same build shares it.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
* Never rejects — a missing/invalid device fingerprint is simply absent.
*/
export const createFingerprintMiddleware = (
opts: { tlsFingerprintHeader?: string } = {},
): RequestHandler => {
const tlsHeader = opts.tlsFingerprintHeader?.toLowerCase();
export const createFingerprintMiddleware = (): RequestHandler => {
return (req, _res, next) => {
req.networkFingerprint = computeNetworkFingerprint(req);
req.deviceFingerprint = readDeviceFingerprint(req);
req.tlsFingerprint = readTlsFingerprint(req, tlsHeader);
next();
};
};
@@ -20,7 +20,6 @@
import type { Request, RequestHandler, Response } from 'express';
import { actorUid } from '../../actor';
import { HttpError } from '../HttpError';
import { onResponseClose } from '../onResponseClose';
import { createJsonBodyParser } from './jsonBody';
/** The global JSON parser's limit, which applies before auth. */
@@ -153,7 +152,7 @@ export const createLargeJsonBodyParser = ({
}
inFlightBytes += declared;
inFlightByUser.set(user, userBytes + declared);
onResponseClose(res, () => {
res.once('close', () => {
inFlightBytes -= declared;
const left = (inFlightByUser.get(user) ?? 0) - declared;
if (left > 0) inFlightByUser.set(user, left);
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
* Browsers send none of those variations, but config values do — a hand-edited
* `origin` with a trailing slash would otherwise reject every real request.
*/
export const normalizeOrigin = (raw: string | undefined): string =>
const normalizeOrigin = (raw: string | undefined): string =>
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
/**
@@ -21,7 +21,6 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import { onResponseClose } from '../onResponseClose.js';
import { subscriptionOverride } from '../../../services/metering/consts.js';
/**
@@ -1170,7 +1169,7 @@ export function concurrencyGate(opts) {
const { release } = guardSlot(slot, '[concurrent]');
const onDone = () => void release();
res.once('finish', onDone);
onResponseClose(res, onDone);
res.once('close', onDone);
if (res.destroyed || res.writableFinished) onDone();
next();
};
@@ -1,105 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { EventEmitter } from 'node:events';
import { createServer, get, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { onResponseClose } from './onResponseClose.js';
let server: Server | undefined;
afterEach(async () => {
server?.closeAllConnections();
await new Promise((resolve) => server?.close(resolve) ?? resolve(null));
server = undefined;
});
describe('onResponseClose', () => {
it('shares one close listener and runs every callback once, in order', () => {
const res = new EventEmitter();
const calls: string[] = [];
onResponseClose(res, () => calls.push('a'));
onResponseClose(res, () => calls.push('b'));
onResponseClose(res, () => calls.push('c'));
expect(res.listenerCount('close')).toBe(1);
res.emit('close');
res.emit('close');
expect(calls).toEqual(['a', 'b', 'c']);
expect(res.listenerCount('close')).toBe(0);
});
it('keeps responses apart', () => {
const first = new EventEmitter();
const second = new EventEmitter();
const onFirst = vi.fn();
const onSecond = vi.fn();
onResponseClose(first, onFirst);
onResponseClose(second, onSecond);
first.emit('close');
expect(onFirst).toHaveBeenCalledOnce();
expect(onSecond).not.toHaveBeenCalled();
});
it('runs the rest when one callback throws, and still surfaces the error', async () => {
const res = new EventEmitter();
const after = vi.fn();
const failure = new Error('callback broke');
const uncaught = vi.fn();
const listeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
process.on('uncaughtException', uncaught);
try {
onResponseClose(res, () => {
throw failure;
});
onResponseClose(res, after);
res.emit('close');
expect(after).toHaveBeenCalledOnce();
await vi.waitFor(() =>
expect(uncaught).toHaveBeenCalledWith(
failure,
expect.anything(),
),
);
} finally {
process.off('uncaughtException', uncaught);
for (const listener of listeners)
process.on('uncaughtException', listener);
}
});
it('fires when a client hangs up on a real response', async () => {
const closed = new Promise<boolean>((resolve) => {
server = createServer((_req, res) => {
onResponseClose(res, () => resolve(res.writableFinished));
res.write('partial');
});
});
await new Promise<void>((resolve) => server!.listen(0, resolve));
const { port } = server!.address() as AddressInfo;
const req = get(`http://127.0.0.1:${port}/`, (res) => {
res.once('data', () => req.destroy());
});
req.on('error', () => {});
expect(await closed).toBe(false);
});
});
-57
View File
@@ -1,57 +0,0 @@
/*
* 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/>.
*/
/** Only what's used here, so test doubles and express's Response both fit. */
interface ClosingResponse {
once(event: 'close', listener: () => void): unknown;
}
const callbacksByResponse = new WeakMap<ClosingResponse, Array<() => void>>();
/**
* Runs `callback` once when the response closes. Every callback on a response
* shares one `close` listener, since Node warns past ten and request tracing
* adds its own. Callbacks run in the order added; one that throws doesn't stop
* the rest.
*/
export const onResponseClose = (
res: ClosingResponse,
callback: () => void,
): void => {
const callbacks = callbacksByResponse.get(res);
if (callbacks) {
callbacks.push(callback);
return;
}
const pending = [callback];
callbacksByResponse.set(res, pending);
res.once('close', () => {
for (const run of pending.splice(0)) {
try {
run();
} catch (e) {
// Surfaces as an uncaught error, as it would from a listener
// of its own.
queueMicrotask(() => {
throw e;
});
}
}
});
};
@@ -1,147 +0,0 @@
/*
* 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 {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/**
* The routes that hold a response open the longest, where middleware, gates and
* the handler each want to hear when it closes.
*/
describe('close listeners per response over HTTP', () => {
let env: PuterTestEnv;
let filePath = '';
beforeAll(async () => {
env = await setupPuterTestEnv({
meteringEnforcement: { subscriptions: false },
} as never);
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
filePath = `/${username}/Documents/close-listeners.txt`;
await env.server.services.fs.write(user!.id, {
fileMetadata: {
path: filePath,
size: 4096,
contentType: 'text/plain',
},
fileContent: Buffer.alloc(4096, 97),
});
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const streamedChat = {
model: 'fake',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
};
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Origin: env.apiOrigin,
},
body: JSON.stringify(body),
});
const routes: Array<[string, string, () => Promise<Response>]> = [
[
'a streamed driver call',
'/drivers/call',
() =>
post('/drivers/call', env.users.user.token, {
interface: 'puter-chat-completion',
service: 'ai-chat',
method: 'complete',
args: streamedChat,
}),
],
[
'an Anthropic-style streamed completion',
'/puterai/anthropic/v1/messages',
() =>
post(
'/puterai/anthropic/v1/messages',
env.users.user.apiToken,
streamedChat,
),
],
[
'an OpenAI-style streamed completion',
'/puterai/openai/v1/chat/completions',
() =>
post(
'/puterai/openai/v1/chat/completions',
env.users.user.apiToken,
streamedChat,
),
],
[
'a file read',
'/read',
() => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
[
'a v2 file read',
'/fs/read',
() => {
const url = new URL('/fs/read', env.apiOrigin);
url.searchParams.set('path', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
];
it.each(routes)('%s stays within the budget', async (_name, path, call) => {
const tracker = trackResponseCloseListeners();
try {
const response = await call();
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak((req) => req.url?.split('?')[0] === path);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
+1 -2
View File
@@ -21,7 +21,6 @@ import type { Request, RequestHandler } from 'express';
import { actorUid } from '../actor';
import type { EventClient } from '../../clients/event/EventClient';
import type { LifecycleRejectStatus } from '../../clients/event/types';
import { onResponseClose } from './onResponseClose';
import type { RouteMethod } from './types';
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
@@ -180,7 +179,7 @@ export const createRouteLifecycleMiddleware = (
);
};
res.once('finish', () => settle(false));
onResponseClose(res, () => settle(!res.writableFinished));
res.once('close', () => settle(!res.writableFinished));
next();
};
};
@@ -19,9 +19,9 @@
import { createServer, type Server } from 'node:http';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../../../testUtil.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
import { clearUnhealthyRoutes } from './utils/providerHealth.js';
import { makeActor } from '../../../../core/actor.js';
import { makeActor } from '../../core/actor.js';
const policyMessage =
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://vendor.test/private';
@@ -19,8 +19,8 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../../../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../../../testUtil.js';
import { makeActor, type Actor } from '../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const routes = [
'/drivers/call',
@@ -42,22 +42,22 @@ import {
vi,
} from 'vitest';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../shared/integrationTestUtil.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { AzureChatProvider } from '../../providers/azure/chat/AzureChatProvider.js';
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
import { NeuralwattProvider } from '../../providers/neuralwatt/chat/NeuralwattProvider.js';
import { OpenAiChatProvider } from '../../providers/openai/chat/OpenAiChatCompletionsProvider.js';
import { OpenRouterProvider } from '../../providers/openrouter/chat/OpenRouterProvider.js';
import { TogetherAIProvider } from '../../providers/together/chat/TogetherAIProvider.js';
import { XAI_CHAT_MODELS } from '../../providers/xai/chat/catalog.js';
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
import { handle_completion_output } from '../../protocols/openaiCompatible/OpenAIUtil.js';
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { InfronProvider } from './providers/infron/InfronProvider.js';
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { handle_completion_output } from './utils/OpenAIUtil.js';
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
let server: PuterServer;
@@ -38,11 +38,11 @@ import {
vi,
} from 'vitest';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../shared/integrationTestUtil.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import type { IChatModel, ICompleteArguments } from './types.js';
// The puter_path resolver reads the user's filesystem; stub it and assert on
@@ -51,7 +51,7 @@ const { processPuterPathUploadsMock } = vi.hoisted(() => ({
processPuterPathUploadsMock: vi.fn(async (_messages: unknown) => {}),
}));
vi.mock('../../providers/openai/chat/fileUpload.js', () => ({
vi.mock('./providers/openai/fileUpload.js', () => ({
processPuterPathUploads: processPuterPathUploadsMock,
MAX_FILE_SIZE: 5 * 1_000_000,
}));
@@ -38,14 +38,14 @@ import {
vi,
} from 'vitest';
import { Context } from '../../../../core/context.js';
import type { UsageInput } from '../../../../services/metering/types.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../shared/integrationTestUtil.js';
import { Context } from '../../core/context.js';
import type { UsageInput } from '../../services/metering/types.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import type { IChatCompleteResult } from './types.js';
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
import type { AIChatStream } from './utils/Streaming.js';
@@ -44,21 +44,21 @@ import type { Request } from 'express';
import {
renderAnthropicError,
renderOpenAIError,
} from '../../../../controllers/puterai/wireErrors.js';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../shared/integrationTestUtil.js';
} from '../../controllers/puterai/wireErrors.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { OPEN_AI_MODELS } from '../../providers/openai/chat/models.js';
import { OPEN_AI_MODELS } from './providers/openai/models.js';
import {
clearUnhealthyRoutes,
isRouteUnhealthy,
markRouteUnhealthy,
} from './utils/providerHealth.js';
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
import { UpstreamDeadlineError } from '../../../util/upstreamErrors.js';
import { InfronProvider } from './providers/infron/InfronProvider.js';
import { UpstreamDeadlineError } from '../util/upstreamErrors.js';
// -- OpenAI SDK mock ------------------------------------------------
// Gemini reaches Google through `new openai.OpenAI()` (default export) and
@@ -44,17 +44,17 @@ import {
vi,
} from 'vitest';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../shared/integrationTestUtil.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withTestActor } from '../integrationTestUtil.js';
import { COUNT_TOKENS, ChatCompletionDriver } from './ChatCompletionDriver.js';
import { ClaudeProvider } from '../../providers/claude/chat/ClaudeProvider.js';
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
import { OllamaChatProvider } from '../../providers/ollama/chat/OllamaProvider.js';
import { XAI_CHAT_MODELS } from '../../providers/xai/chat/catalog.js';
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import type {
IChatCompleteResult,
IChatProvider,
@@ -2396,97 +2396,3 @@ describe('ChatCompletionDriver service speed', () => {
},
);
});
describe('ChatCompletionDriver.complete option shapes', () => {
const hi = [{ role: 'user', content: 'hi' }];
it.each(['model', 'provider', 'service_tier'])(
'rejects a value that cannot be text as `%s`',
async (name) => {
const args = { model: 'fake', messages: hi, [name]: { toString: null } };
await expect(
withTestActor(() => driver.complete(args as never)),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: `\`${name}\` is not a usable value`,
});
await expect(
withTestActor(() => driver[COUNT_TOKENS](args as never)),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it('routes by the model when `provider` is an object, as it did before', async () => {
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
await withTestActor(() =>
driver.complete({
model: 'fake',
messages: hi,
provider: { order: ['Anthropic'] },
} as never),
);
expect(completeSpy).toHaveBeenCalledTimes(1);
});
it.each([false, 0, '', null])(
'treats `tools: %j` as no tools when counting tokens',
async (tools) => {
const countTokens = vi.fn(async (_args: object) => 1);
const fake = FakeChatProvider.prototype as {
countTokens?: typeof countTokens;
};
fake.countTokens = countTokens;
try {
await withTestActor(() =>
driver[COUNT_TOKENS]({
model: 'fake',
messages: hi,
tools,
} as never),
);
} finally {
delete fake.countTokens;
}
expect(countTokens.mock.calls[0]![0]).not.toHaveProperty('tools');
},
);
it.each([false, 0, '', null])(
'treats `tools: %j` as no tools',
async (tools) => {
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
await withTestActor(() =>
driver.complete({ model: 'fake', messages: hi, tools } as never),
);
expect(completeSpy.mock.calls[0]![0]).not.toHaveProperty('tools');
},
);
it('reports zero tokens in the cost event for a usage count that is not a number', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'priced',
aliases: [],
costs_currency: 'usd-cents',
costs: { input_tokens: 1000, output_tokens: 2000 },
max_tokens: 8192,
},
]);
const d = await makeDriver();
const costEvents = captureEvent('ai.prompt.cost-calculated');
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
usage: { input_tokens: 10, output_tokens: { toString: null } },
finish_reason: 'stop',
} as never);
await withTestActor(() => d.complete({ model: 'priced', messages: hi }));
expect(costEvents[0]![1]).toMatchObject({
input_tokens: 10,
output_tokens: 0,
total_ucents: 10 * 1000,
});
});
});
@@ -19,56 +19,56 @@
import crypto from 'node:crypto';
import { PassThrough } from 'node:stream';
import { EventMap } from '../../../../clients/event/types.js';
import type { Actor } from '../../../../core/actor.js';
import { Context } from '../../../../core/context.js';
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
import { isFreeSubscription } from '../../../../services/metering/consts.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { CreditHold } from '../../../../services/metering/types.js';
import { NO_CREDIT_HOLD } from '../../../../services/metering/types.js';
import type { DriverStreamResult } from '../../../meta.js';
import { PuterDriver } from '../../../types.js';
import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import { insufficientCreditsError } from '../../services/metering/enforcement.js';
import { isFreeSubscription } from '../../services/metering/consts.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { CreditHold } from '../../services/metering/types.js';
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import {
type AiMeteringService,
factoredTotal,
requestAiCosts,
withAiCostFactor,
} from '../../../../util/aiCostFactor.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../shared/aiLimits.js';
} from '../util/aiCostFactor.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import {
isCreditExhaustion as isUpstreamCreditExhaustion,
isUpstreamConnectionError,
isUpstreamTimeoutError,
sanitizeUpstreamMessage,
UpstreamDeadlineError,
} from '../../../util/upstreamErrors.js';
import { AlibabaProvider } from '../../providers/alibaba/chat/AlibabaProvider.js';
import { AzureChatProvider } from '../../providers/azure/chat/AzureChatProvider.js';
import { AzureResponsesProvider } from '../../providers/azure/chat/AzureResponsesProvider.js';
import { BytePlusProvider } from '../../providers/byteplus/chat/BytePlusProvider.js';
import { ClaudeProvider } from '../../providers/claude/chat/ClaudeProvider.js';
import { DeepSeekProvider } from '../../providers/deepseek/chat/DeepSeekProvider.js';
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
import { GeminiChatProvider } from '../../providers/gemini/chat/GeminiChatProvider.js';
import { GroqAIProvider } from '../../providers/groq/chat/GroqAIProvider.js';
import { HoonifyProvider } from '../../providers/hoonify/chat/HoonifyProvider.js';
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
import { MetaProvider } from '../../providers/meta/chat/MetaProvider.js';
import { MiniMaxProvider } from '../../providers/minimax/chat/MiniMaxProvider.js';
import { MistralAIProvider } from '../../providers/mistral/chat/MistralAiProvider.js';
import { MoonshotProvider } from '../../providers/moonshot/chat/MoonshotProvider.js';
import { NeuralwattProvider } from '../../providers/neuralwatt/chat/NeuralwattProvider.js';
import { OllamaChatProvider } from '../../providers/ollama/chat/OllamaProvider.js';
import type { ChatProviderConfig } from '../../protocols/openaiCompatible/OpenAICompatProvider.js';
import { processPuterPathUploads } from '../../providers/openai/chat/fileUpload.js';
import { OpenAiChatProvider } from '../../providers/openai/chat/OpenAiChatCompletionsProvider.js';
import { OpenAiResponsesChatProvider } from '../../providers/openai/chat/OpenAiChatResponsesProvider.js';
import { OpenRouterProvider } from '../../providers/openrouter/chat/OpenRouterProvider.js';
import { TogetherAIProvider } from '../../providers/together/chat/TogetherAIProvider.js';
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
import { ZAIProvider } from '../../providers/zai/chat/ZAIProvider.js';
} from '../util/upstreamErrors.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
import { HoonifyProvider } from './providers/hoonify/HoonifyProvider.js';
import { InfronProvider } from './providers/infron/InfronProvider.js';
import { MetaProvider } from './providers/meta/MetaProvider.js';
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
import type { ChatProviderConfig } from './providers/OpenAICompatProvider.js';
import { processPuterPathUploads } from './providers/openai/fileUpload.js';
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
import { OpenAiResponsesChatProvider } from './providers/openai/OpenAiChatResponsesProvider.js';
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
import type {
IChatCompleteResult,
IChatMessageResult,
@@ -103,13 +103,9 @@ import {
normalizeResultToOpenAI,
shouldPresentAsOpenAI,
} from './utils/normalizeToOpenAI.js';
import { isToolChoice, toolChoiceFromWire } from './utils/openaiParams.js';
import {
isToolChoice,
toolChoiceFromWire,
} from '../../protocols/openaiCompatible/openaiParams.js';
import { assertScalarOptions } from '../../shared/scalarOptions.js';
import { catalogPrice } from '../../shared/catalogPrice.js';
import {
catalogPrice,
costKeys,
costsForRequest,
isFreeModel,
@@ -125,7 +121,7 @@ import {
TRANSIENT_UNHEALTHY_TTL_SEC,
UNHEALTHY_TTL_SEC,
} from './utils/providerHealth.js';
import { CATALOG_TTL_MS } from '../../shared/cachedRemoteCatalog.js';
import { CATALOG_TTL_MS } from './utils/cachedRemoteCatalog.js';
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
import { fromFinishReason } from './utils/stopReason.js';
import { AIChatStream } from './utils/Streaming.js';
@@ -182,9 +178,6 @@ export const COUNT_TOKENS = Symbol('aiChat.countTokens');
*/
const HOLD_RENEW_INTERVAL_MS = 5 * 60 * 1000;
// Options that reach error messages and request headers as text.
const SCALAR_OPTIONS = ['model', 'provider', 'service_tier'];
/**
* A moderation refusal is a completion that was produced and charged, then
* withheld — not a route failure. Retrying it on a fallback provider would bill
@@ -592,7 +585,7 @@ export class ChatCompletionDriver extends PuterDriver {
readonly driverName = 'ai-chat';
readonly isDefault = true;
// Shared AI policy — see `drivers/ai/shared/aiLimits.ts` for the tier table.
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
@@ -697,7 +690,6 @@ export class ChatCompletionDriver extends PuterDriver {
{ legacyCode: 'invalid_service_speed' },
);
}
assertScalarOptions(args, SCALAR_OPTIONS);
const abortSignal = Context.get('abortSignal');
abortSignal?.throwIfAborted();
const actor = Context.get('actor');
@@ -752,9 +744,6 @@ export class ChatCompletionDriver extends PuterDriver {
args.messages = normalizeMediaParts(normalize_messages(args.messages));
if (args.tools) {
normalize_tools_object(args.tools);
} else {
// `tools: enabled && list` leaves `false` here; it means no tools.
delete args.tools;
}
// A raw `/drivers/call` caller still sends the OpenAI wire form
// ('auto'/'none'/'required', {type:'function', function:{name}}) —
@@ -1501,7 +1490,6 @@ export class ChatCompletionDriver extends PuterDriver {
async [COUNT_TOKENS](
args: ICompleteArguments,
): Promise<{ input_tokens: number }> {
assertScalarOptions(args, SCALAR_OPTIONS);
let intendedProvider = args.provider || '';
if (!args.model && !intendedProvider) {
intendedProvider = 'azure-openai';
@@ -1527,9 +1515,6 @@ export class ChatCompletionDriver extends PuterDriver {
args.messages = normalizeMediaParts(normalize_messages(args.messages));
if (args.tools) {
normalize_tools_object(args.tools);
} else {
// `tools: enabled && list` leaves `false` here; it means no tools.
delete args.tools;
}
const provider = this.#providers[model.provider!];
@@ -1863,20 +1848,20 @@ export class ChatCompletionDriver extends PuterDriver {
const cost = usageCost(usage, model, usageCosts, pricingTime);
const { inputKey, outputKey } = costKeys(model);
// The vendor reports usage, and pricing skips a count that is not a
// number; so does this.
const count = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : 0;
const inputTokens = cost
? count(
usage[inputKey] ?? usage.prompt_tokens ?? usage.input_tokens,
? Number(
usage[inputKey] ??
usage.prompt_tokens ??
usage.input_tokens ??
0,
)
: 0;
const outputTokens = cost
? count(
? Number(
usage[outputKey] ??
usage.completion_tokens ??
usage.output_tokens,
usage.output_tokens ??
0,
)
: 0;
const inputMicroCents = cost?.inputMicroCents ?? 0;
@@ -19,9 +19,9 @@
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { Context } from '../../../../../core/context.js';
import { withTestActor } from '../../../shared/integrationTestUtil.js';
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
import { Context } from '../../../core/context.js';
import { withTestActor } from '../../integrationTestUtil.js';
import { AIChatStream } from '../utils/Streaming.js';
import { FakeChatProvider } from './FakeChatProvider.js';
describe('fake chat streaming lifecycle', () => {
@@ -19,15 +19,15 @@
import dedent from 'dedent';
import { setTimeout as delay } from 'node:timers/promises';
import { Context } from '../../../../../core/context.js';
import { Context } from '../../../core/context.js';
import { LoremIpsum } from 'lorem-ipsum';
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
import { AIChatStream } from '../utils/Streaming.js';
import {
IChatModel,
IChatProvider,
ICompleteArguments,
PuterMessage,
} from '../../../interfaces/chat/types.js';
} from '../types.js';
export class FakeChatProvider implements IChatProvider {
checkModeration(_text: string) {
@@ -18,12 +18,12 @@
*/
import { describe, expect, it } from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { MeteringService } from '../../../services/metering/MeteringService.js';
import type {
IChatMessageResult,
IChatModel,
ICompleteArguments,
} from '../../interfaces/chat/types.js';
} from '../types.js';
import {
type OpenAICompatOptions,
OpenAICompatProvider,
@@ -18,10 +18,10 @@
*/
import type { CompletionUsage } from 'openai/resources/completions.mjs';
import type { Actor } from '../../../../core/actor.js';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { Actor } from '../../../core/actor.js';
import { Context } from '../../../core/context.js';
import { HttpError } from '../../../core/http/HttpError.js';
import type { MeteringService } from '../../../services/metering/MeteringService.js';
import type {
IChatCompleteResult,
IChatModel,
@@ -29,27 +29,26 @@ import type {
ICompleteArguments,
PuterMessage,
UsageDetails,
} from '../../interfaces/chat/types.js';
} from '../types.js';
import {
contextLengthRetryParams,
isContextLengthError,
} from './contextLimit.js';
import { inlineHttpImageUrls } from './inlineImages.js';
import { modelSupportsVision } from '../../interfaces/chat/utils/mediaParts.js';
} from '../utils/contextLimit.js';
import { make_openai_tools } from '../utils/FunctionCalling.js';
import { inlineHttpImageUrls } from '../utils/inlineImages.js';
import { modelSupportsVision } from '../utils/mediaParts.js';
import {
meterChatUsage,
type MeterChatUsageOptions,
} from '../../interfaces/chat/utils/meterChatUsage.js';
import { servedServiceSpeed } from '../../interfaces/chat/utils/serviceSpeed.js';
import { costKeys } from '../../interfaces/chat/utils/pricing.js';
import { catalogPrice } from '../../shared/catalogPrice.js';
import { normalizeModelKey } from '../../interfaces/chat/utils/modelRouting.js';
import * as OpenAIUtil from './OpenAIUtil.js';
} from '../utils/meterChatUsage.js';
import { servedServiceSpeed } from '../utils/serviceSpeed.js';
import { catalogPrice, costKeys } from '../utils/pricing.js';
import { normalizeModelKey } from '../utils/modelRouting.js';
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
import {
openAICompatParams,
type OpenAIDialect,
toolsToWire,
} from './openaiParams.js';
} from '../utils/openaiParams.js';
/** What the driver hands every keyed provider. */
export interface ChatProviderConfig {
@@ -201,7 +200,9 @@ export class OpenAICompatProvider implements IChatProvider {
model,
);
const tools = args.tools ? toolsToWire(args.tools, 'chat') : undefined;
const tools = args.tools
? make_openai_tools(args.tools, { dialect: 'chat' })
: undefined;
const maxTokens = args.max_tokens ?? options.defaultMaxTokens;
const streamUsage = options.streamUsage ?? true;
const shared: Record<string, unknown> = {
@@ -28,9 +28,10 @@ import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../shared/integrationTestUtil.js';
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
} from '../../../integrationTestUtil.js';
import { AlibabaProvider } from './AlibabaProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
@@ -41,19 +41,19 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../../server.js';
import { setupTestServer } from '../../../../../testUtil.js';
import { withTestActor } from '../../../shared/integrationTestUtil.js';
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { mapAlibabaCatalogModel, type AlibabaApiModel } from './catalog.js';
import page1 from './fixtures/page1.test.json';
import page2 from './fixtures/page2.test.json';
import { CATALOG_TTL_MS } from '../../../shared/cachedRemoteCatalog.js';
import { CATALOG_TTL_MS } from '../../utils/cachedRemoteCatalog.js';
import { ALIBABA_MODELS } from './models.js';
import { AlibabaProvider } from './AlibabaProvider.js';
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
@@ -18,15 +18,15 @@
*/
import { OpenAI } from 'openai';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import type { IChatModel } from '../../../interfaces/chat/types.js';
import { cachedRemoteCatalog } from '../../../shared/cachedRemoteCatalog.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { fetchAlibabaCatalog } from './catalog.js';
import { sdkClientOptions } from '../../../interfaces/chat/utils/sdkClient.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
OpenAICompatProvider,
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
} from '../OpenAICompatProvider.js';
import { ALIBABA_MODELS } from './models.js';
export class AlibabaProvider extends OpenAICompatProvider {
@@ -18,8 +18,8 @@
*/
import axios from 'axios';
import type { IChatModel } from '../../../interfaces/chat/types.js';
import { catalogPrice } from '../../../shared/catalogPrice.js';
import type { IChatModel } from '../../types.js';
import { catalogPrice } from '../../utils/pricing.js';
import { ALIBABA_MODELS } from './models.js';
export interface AlibabaApiModel {
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { IChatModel } from '../../../interfaces/chat/types.js';
import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const ALIBABA_MODELS: IChatModel[] = [
@@ -36,9 +36,10 @@ import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../shared/integrationTestUtil.js';
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
} from '../../../integrationTestUtil.js';
import { AzureChatProvider } from './AzureChatProvider.js';
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
@@ -46,17 +46,17 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR, makeActor } from '../../../../../core/actor.js';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../../server.js';
import { setupTestServer } from '../../../../../testUtil.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../shared/integrationTestUtil.js';
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureChatProvider } from './AzureChatProvider.js';
import { AZURE_MODELS } from './models.js';
@@ -85,7 +85,7 @@ const { inlineHttpImageUrlsMock } = vi.hoisted(() => ({
inlineHttpImageUrlsMock: vi.fn(async (_messages: unknown) => {}),
}));
vi.mock('../../../protocols/openaiCompatible/inlineImages.js', () => ({
vi.mock('../../utils/inlineImages.js', () => ({
inlineHttpImageUrls: inlineHttpImageUrlsMock,
}));
@@ -23,33 +23,33 @@ import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import {
serviceSpeedBillingModel,
servedServiceSpeed,
} from '../../../interfaces/chat/utils/serviceSpeed.js';
import { Context } from '../../../../../core/context.js';
import type { FSService } from '../../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
} from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type {
IChatModel,
IChatProvider,
ICompleteArguments,
} from '../../../interfaces/chat/types.js';
} from '../../types.js';
import {
messagesHaveCompaction,
wantsCompaction,
} from '../../../interfaces/chat/utils/compaction.js';
import * as OpenAiUtil from '../../../protocols/openaiCompatible/OpenAIUtil.js';
} from '../../utils/compaction.js';
import { make_openai_tools } from '../../utils/FunctionCalling.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import {
clampReasoningEffort,
openAICompatParams,
toolsToWire,
} from '../../../protocols/openaiCompatible/openaiParams.js';
import { buildCostsOverride } from '../../../interfaces/chat/utils/pricing.js';
import { inlineHttpImageUrls } from '../../../protocols/openaiCompatible/inlineImages.js';
import { processPuterPathUploads } from '../../openai/chat/fileUpload.js';
} from '../../utils/openaiParams.js';
import { buildCostsOverride } from '../../utils/pricing.js';
import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../shared/upstreamIdentifier.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
const isWebSearchTool = (tool: Record<string, unknown>): boolean =>
tool.type === 'web_search' ||
@@ -256,7 +256,9 @@ export class AzureChatProvider implements IChatProvider {
// that's missing. We normalise it here so the token count code works.
messages = await OpenAiUtil.process_input_messages(messages);
const mappedTools = tools ? toolsToWire(tools, 'chat') : undefined;
const mappedTools = tools
? make_openai_tools(tools, { dialect: 'chat' })
: undefined;
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
const requestedVerbosity = verbosity ?? text?.verbosity;
@@ -34,9 +34,10 @@ import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../shared/integrationTestUtil.js';
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
} from '../../../integrationTestUtil.js';
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
@@ -40,17 +40,17 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR, makeActor } from '../../../../../core/actor.js';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../../server.js';
import { setupTestServer } from '../../../../../testUtil.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../shared/integrationTestUtil.js';
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
import { AZURE_MODELS } from './models.js';
@@ -22,33 +22,33 @@ import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
import {
serviceSpeedBillingModel,
servedServiceSpeed,
} from '../../../interfaces/chat/utils/serviceSpeed.js';
import { Context } from '../../../../../core/context.js';
import type { FSService } from '../../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
} from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
import type {
IChatModel,
IChatProvider,
ICompleteArguments,
} from '../../../interfaces/chat/types.js';
import * as OpenAiUtil from '../../../protocols/openaiCompatible/OpenAIUtil.js';
} from '../../types.js';
import { toOpenAiContextManagement } from '../../utils/compaction.js';
import { make_openai_tools } from '../../utils/FunctionCalling.js';
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
import {
clampReasoningEffort,
openAICompatParams,
promptCacheRetentionToWire,
rejectStatefulResponsesFields,
toolsToWire,
toOpenAiContextManagement,
} from '../../../protocols/openaiCompatible/openaiParams.js';
import { buildCostsOverride } from '../../../interfaces/chat/utils/pricing.js';
import { responseSamplingParams } from '../../../protocols/openaiCompatible/responseSampling.js';
import { processPuterPathUploads } from '../../openai/chat/fileUpload.js';
} from '../../utils/openaiParams.js';
import { buildCostsOverride } from '../../utils/pricing.js';
import { responseSamplingParams } from '../../utils/responseSampling.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../shared/upstreamIdentifier.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
const ANTHROPIC_WEB_SEARCH_TYPE = (type: unknown): boolean =>
type === 'web_search_20250305' ||
@@ -245,7 +245,9 @@ export class AzureResponsesProvider implements IChatProvider {
}
});
}
const mappedTools = tools ? toolsToWire(tools, 'responses') : undefined;
const mappedTools = tools
? make_openai_tools(tools, { dialect: 'responses' })
: undefined;
// Strip Anthropic-only shape a fallback-replayed message can carry
// before the existing in-place coercion mutates only this pass's copy.
@@ -25,9 +25,9 @@
import { describe, expect, it } from 'vitest';
import type { IChatModel } from '../../../interfaces/chat/types.js';
import { OPEN_AI_MODELS } from '../../openai/chat/models.js';
import { XAI_MODELS } from '../../xai/chat/models.js';
import type { IChatModel } from '../../types.js';
import { OPEN_AI_MODELS } from '../openai/models.js';
import { XAI_MODELS } from '../xai/models.js';
import { AZURE_MODELS } from './models.js';
// Deployments of models the direct providers have retired: nothing to mirror.
@@ -17,9 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { IChatModel } from '../../../interfaces/chat/types.js';
import { OPEN_AI_MODELS } from '../../openai/chat/models.js';
import { XAI_MODELS } from '../../xai/chat/models.js';
import type { IChatModel } from '../../types.js';
import { OPEN_AI_MODELS } from '../openai/models.js';
import { XAI_MODELS } from '../xai/models.js';
/**
* Azure shares its source model's list prices, context tiers, and limits.
@@ -54,10 +54,10 @@ const mirror = (
// own provider folder rather than sharing the OpenAI list.
//
// IMPORTANT: we bill users at the public list price of the equivalent OpenAI /
// xAI model, which `mirror` copies from `../../openai/chat/models.ts` and
// `../../xai/chat/models.ts`. Azure is subsidised for us, so our actual spend
// is lower — but billing at the standard model price is the whole reason we
// route through Azure. Do NOT replace these with Azure's own rates.
// xAI model, which `mirror` copies from `../openai/models.ts` and
// `../xai/models.ts`. Azure is subsidised for us, so our actual spend is lower
// — but billing at the standard model price is the whole reason we route
// through Azure. Do NOT replace these with Azure's own rates.
//
// `id` is the Azure deployment name and is what we send upstream.
export const AZURE_MODELS: IChatModel[] = [
Loaded 100 of 576 files, more files were not shown because too many files have changed in this diff. Show more