mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e11e7dc79 | ||
|
|
5b1b5b8a7a | ||
|
|
b2eb8ed895 | ||
|
|
d1cbfb9fb1 | ||
|
|
ac44de819d | ||
|
|
4ad53899da | ||
|
|
c253b7e05e | ||
|
|
647995133e | ||
|
|
9d3a049988 | ||
|
|
1c5909331c | ||
|
|
5d541cd8e2 | ||
|
|
fb526bfb7e | ||
|
|
1abd25eb31 | ||
|
|
b5e4f20087 | ||
|
|
bdb88507df | ||
|
|
0694b76751 | ||
|
|
e7ee778985 | ||
|
|
c3ff8ca3b2 | ||
|
|
39d931306d | ||
|
|
70e823c5af | ||
|
|
1bc1953344 | ||
|
|
0f83bb9b56 | ||
|
|
1ae95d97e8 | ||
|
|
31f8936da2 | ||
|
|
aab559c9c1 | ||
|
|
7f1d46028a | ||
|
|
39d9482409 | ||
|
|
4a958b3ca1 | ||
|
|
4653ff6dae | ||
|
|
a5c7b427b8 | ||
|
|
cd3600bae9 | ||
|
|
2db55bd636 | ||
|
|
36e2d199c8 | ||
|
|
8616b00f7b | ||
|
|
873303a7c4 | ||
|
|
11107f3108 | ||
|
|
2c3b69011f | ||
|
|
eefc1f2f2e | ||
|
|
7e093d2ba7 | ||
|
|
bd7614372a | ||
|
|
61b5aa4900 | ||
|
|
a6e75b46cb | ||
|
|
dd32c7f9b2 | ||
|
|
aaa5c82236 | ||
|
|
8b68c103c5 | ||
|
|
454f94fc54 | ||
|
|
8b7d380de1 | ||
|
|
8052a4287e | ||
|
|
f114b13306 | ||
|
|
ce70728eeb | ||
|
|
8518023b0b | ||
|
|
33988ac84c | ||
|
|
72de6e16b2 | ||
|
|
234994d3af | ||
|
|
eef2d089a8 | ||
|
|
711ece5800 | ||
|
|
40ea3686da | ||
|
|
db7ca8382b | ||
|
|
0f2c325598 | ||
|
|
ffcfb85749 | ||
|
|
44bf99f808 | ||
|
|
d392b6857d | ||
|
|
d8c3e01dd9 | ||
|
|
4bc679956f | ||
|
|
b751f75467 | ||
|
|
224b2f2367 | ||
|
|
80bcc4ba14 | ||
|
|
f26201649a | ||
|
|
1550be358d | ||
|
|
8a15f09f48 | ||
|
|
2084587885 | ||
|
|
222ffcfe39 | ||
|
|
7494aa1cbc | ||
|
|
1adca8a794 | ||
|
|
07b19888a4 | ||
|
|
2394221745 | ||
|
|
ee2cdbfe1a | ||
|
|
9b6350beab | ||
|
|
b649d9ada7 | ||
|
|
50b0639d02 | ||
|
|
ab96702718 | ||
|
|
47f9b0bdf3 | ||
|
|
8c4b0eb1fc | ||
|
|
a88c1b9d65 | ||
|
|
9caaf7162c | ||
|
|
0191f2bfec | ||
|
|
7aadc48cca | ||
|
|
6b06793750 | ||
|
|
e441880f13 | ||
|
|
9a86803418 |
No files matched your search
@@ -0,0 +1,106 @@
|
||||
# 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,10 +17,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -23,8 +23,10 @@ jobs:
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/[email protected]
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -52,10 +54,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -80,13 +84,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
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
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -106,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: backend-blob-${{ matrix.shard }}
|
||||
path: .vitest-reports/
|
||||
@@ -146,13 +151,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
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
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -161,7 +167,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
pattern: backend-blob-*
|
||||
path: .vitest-reports
|
||||
@@ -173,7 +179,7 @@ jobs:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: backend-coverage-${{ github.event_name == 'push' && 'main' || 'pr' }}
|
||||
path: src/backend/coverage/
|
||||
@@ -204,7 +210,7 @@ jobs:
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
uses: davelosert/vitest-coverage-report-action@v2.13.2
|
||||
with:
|
||||
json-summary-path: src/backend/coverage/coverage-summary.json
|
||||
json-final-path: src/backend/coverage/coverage-final.json
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
uses: dependabot/fetch-metadata@v3.1.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
|
||||
@@ -45,30 +45,32 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v4.4.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -82,7 +84,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
|
||||
uses: docker/build-push-action@v7.4.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -95,7 +97,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sync README to Docker Hub
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
uses: peter-evans/dockerhub-description@v5.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -5,16 +5,21 @@ on:
|
||||
paths:
|
||||
- 'src/docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -22,8 +22,10 @@ jobs:
|
||||
outputs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/[email protected]
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -52,10 +54,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -73,10 +77,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -130,14 +136,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
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
|
||||
uses: actions/setup-node@v7.1.0
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
@@ -161,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.2
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
@@ -174,13 +181,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# 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
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
@@ -191,14 +200,14 @@ jobs:
|
||||
id: base-coverage
|
||||
if: steps.pr-coverage.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v8.0.2
|
||||
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
|
||||
uses: davelosert/vitest-coverage-report-action@v2.13.2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
@@ -207,7 +216,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
|
||||
uses: davelosert/vitest-coverage-report-action@v2.13.2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -45,14 +45,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v4.4.1
|
||||
|
||||
- name: Create alias tag
|
||||
env:
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (`/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
|
||||
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
|
||||
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
|
||||
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
// 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) ──────────────────────────────
|
||||
@@ -254,6 +258,16 @@
|
||||
"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) ─────────────────────────────────
|
||||
|
||||
@@ -259,6 +259,99 @@ no user to name), puter.js opens the dashboard's upgrade page in a new tab with
|
||||
what was refused, through a consent dialog when the click has expired or the
|
||||
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
|
||||
|
||||
## Top-up
|
||||
|
||||
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
|
||||
buys add-on credit, served and framed like the upgrade: a transparent GUI page
|
||||
covering the host with no box or close button, allowing `payment` and
|
||||
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
|
||||
|
||||
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
|
||||
shows only when whoami reports `subscribed: true` and no `team` paying for the
|
||||
account. On a page that can't frame the GUI, the item opens the dashboard with
|
||||
`?topup=` in a new tab instead.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting, and the deployment defines
|
||||
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UITopUp({ onClose }).open();
|
||||
```
|
||||
|
||||
`open()` resolves once the dialog has loaded the account and shows what it can
|
||||
buy, and `onClose({ toppedUp })` must run exactly once, when the dialog closes
|
||||
or when it declines to open for the account. The GUI then posts `{ msg: 'puterPopupFrame', state: 'closed', toppedUp }`
|
||||
and the page removes the frame. When the frame can't show the top-up, puter.js
|
||||
opens the dashboard's top-up in a new tab, as it does the upgrade.
|
||||
|
||||
While the top-up shows, a call refused for funds still rejects but opens no
|
||||
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
|
||||
resolves `{ upgraded: false }` once the top-up closes.
|
||||
|
||||
## Email confirmation
|
||||
|
||||
`GET /embed/request-email-confirmation` walks a first-party page's user through
|
||||
confirming their email, served like the upgrade: the GUI shell with the same
|
||||
framing rules, redirects and `no-store`, run only when framed by an allowed
|
||||
page. puter.js frames it on a signed-in first-party page for
|
||||
`puter.ui.requestEmailConfirmation()`. Inside an app the desktop shows the same
|
||||
windows over the app instead.
|
||||
|
||||
Like the upgrade, the frame covers the page with no box or close button, over a
|
||||
transparent GUI page, and its fragment names `user`. Escape closes it, on the
|
||||
page or on the save-account or code window. On a window, Escape closes that
|
||||
window as its close button does, so a code already sent still answers. The GUI
|
||||
reports `unavailable` unless its session belongs to `user` and the account has
|
||||
no phone, card or password check waiting.
|
||||
An email check is fine: the boot skips its own email gate for this action, so
|
||||
the user gets one code window they can close.
|
||||
|
||||
The GUI then runs `ensureConfirmedEmail` (`src/gui/src/helpers/ensureConfirmedEmail.js`),
|
||||
which the desktop's handler uses too: a temporary account gets the save-account
|
||||
window first, then an unconfirmed email gets the code window, each at most
|
||||
once. When the page's whoami already reads the email as confirmed,
|
||||
`requestEmailConfirmation()` resolves `{ response: true }` without framing
|
||||
anything, and a confirmation in the frame marks that whoami confirmed for the
|
||||
calls after it. An email the GUI reads as confirmed shows nothing. Before
|
||||
opening a window the GUI waits for the frame to have a viewport, as the upgrade
|
||||
does. It then posts
|
||||
`{ msg: 'puterPopupFrame', state: 'closed', confirmed }`, and
|
||||
`requestEmailConfirmation()` resolves `{ response: confirmed }`, the field an
|
||||
app reads from the desktop's answer. A call made while the frame shows settles
|
||||
with it. A frame that is refused, never ready, or `unavailable` resolves
|
||||
`{ response: false }`, since there's no popup to fall back to.
|
||||
|
||||
## Custom domains
|
||||
|
||||
`GET /embed/attach-custom-domain` is the custom domain dialog of
|
||||
`puter.hosting.attach()`, the popup at `/action/attach-custom-domain`, served
|
||||
like the upgrade: the GUI shell with the same framing rules, redirects and
|
||||
`no-store`, run only when framed by an allowed page. puter.js frames it on a
|
||||
signed-in first-party page (the same pages as the pickers).
|
||||
|
||||
The frame covers the page with no box or close button, over a transparent GUI
|
||||
page, so the dialog floats over the host with its own scrim as it does on the
|
||||
desktop. The frame allows `clipboard-write`, for the dialog's copy buttons, as
|
||||
well as `otp-credentials`. Escape on the page is left to the dialog, which
|
||||
doesn't close while a request is in flight. Its fragment names `user` (the
|
||||
page's signed-in user), `subdomain` (the site to attach a domain to) and
|
||||
`msg_id`.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user` and the
|
||||
account has no verification or password change waiting. It then runs the
|
||||
popup's action unchanged, with the framing page as the requester the dialog
|
||||
names: it posts `ready` once the dialog is on the page, answers
|
||||
`{ msg: 'customDomainAttached', domain, original_msg_id }` once the dialog has
|
||||
closed and its exit has played, and then posts `closed`. `domain` is the domain the user chose, or
|
||||
null. `attach()` resolves it. A frame that closes without an answer, or a
|
||||
loading scrim the user dismisses, resolves null.
|
||||
|
||||
A call made while the frame shows resolves null without replacing it, as a
|
||||
second call does on the desktop. A frame that is refused, never ready, or
|
||||
`unavailable` falls back to the popup, which can sign in, through a consent
|
||||
dialog when the click has expired.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
@@ -266,7 +359,7 @@ Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/modules/UI.attachCustomDomainFrame.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
+6
-6
@@ -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` 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.
|
||||
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.
|
||||
|
||||
## 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(): Promise<{ deviceUid: string }>;
|
||||
enable(options?: { background?: boolean }): Promise<{ deviceUid: string }>;
|
||||
disable(): Promise<void>;
|
||||
status(): Promise<{
|
||||
status(options?: { cached?: boolean }): 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 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.
|
||||
- `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.
|
||||
- `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 `deviceUid` is that device's `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.
|
||||
|
||||
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 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.
|
||||
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.
|
||||
@@ -326,6 +326,20 @@ 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
|
||||
|
||||
+40
-10
@@ -257,8 +257,41 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.created_ts).toBeGreaterThan(0);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('leaves out taskbar items saved on the account', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
expect(captured.body).not.toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('leaves out a desktop background saved on the account', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
desktop_bg_url: 'https://cdn.test/wallpaper.png',
|
||||
desktop_bg_color: '#000000',
|
||||
desktop_bg_fit: 'cover',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('desktop_bg_url');
|
||||
expect(body).not.toHaveProperty('desktop_bg_color');
|
||||
expect(body).not.toHaveProperty('desktop_bg_fit');
|
||||
});
|
||||
|
||||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||||
@@ -322,7 +355,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||||
it('strips created_ts and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -340,9 +373,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Account age, in either form, is not exposed to apps.
|
||||
expect(body.created_ts).toBeUndefined();
|
||||
@@ -432,7 +462,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot add taskbar_items for an app actor, which core omits', async () => {
|
||||
it('cannot add directories for an app actor, which core omits', async () => {
|
||||
const user = await seedUser();
|
||||
const body = await whoamiWithListener(
|
||||
makeActor({
|
||||
@@ -440,11 +470,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
app: { uid: 'app-test-actor' },
|
||||
}),
|
||||
(_key, event) => {
|
||||
event.details.taskbar_items = [{ name: 'injected' }];
|
||||
event.details.directories = { '/injected': 'uuid' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(body).not.toHaveProperty('taskbar_items');
|
||||
expect(body).not.toHaveProperty('directories');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,7 +555,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
// in the payload (nested under metadata, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
|
||||
+2
-16
@@ -1,6 +1,5 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import {
|
||||
buildUserDetails,
|
||||
scrubSensitive,
|
||||
@@ -54,19 +53,10 @@ export const handleWhoami = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
|
||||
const details = await buildUserDetails(
|
||||
user,
|
||||
{ config: extension.config, clients, stores, services },
|
||||
{ isUser, iconSize, noIcons: !iconSize },
|
||||
{ isUser },
|
||||
);
|
||||
details.human_readable_age = user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
@@ -81,9 +71,6 @@ export const handleWhoami = async (
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
delete details.desktop_bg_url;
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
@@ -121,8 +108,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
@@ -190,6 +190,13 @@ S3_BUCKET=puter-local
|
||||
providers = [ordered]@{
|
||||
ollama = [ordered]@{ enabled = $false }
|
||||
}
|
||||
# Off because a self-hosted install has no paid plans: every account
|
||||
# is on the free policy, so the plan gates would 402 the surfaces that
|
||||
# declare one (the OpenAI/Anthropic-compatible AI endpoints) for
|
||||
# everybody. Same as install.sh.
|
||||
meteringEnforcement = [ordered]@{
|
||||
subscriptions = $false
|
||||
}
|
||||
trust_proxy = 1
|
||||
}
|
||||
$configJson = $config | ConvertTo-Json -Depth 10
|
||||
|
||||
Generated
+86
-77
@@ -897,21 +897,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz",
|
||||
"integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@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/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/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.13",
|
||||
"@babel/types": "^7.29.11",
|
||||
"@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.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz",
|
||||
"integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/parser": "^7.29.13",
|
||||
"@babel/types": "^7.29.11",
|
||||
"@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.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -982,22 +982,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"version": "6.3.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
|
||||
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
|
||||
"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",
|
||||
@@ -1023,15 +1016,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
"@babel/traverse": "^7.29.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1051,9 +1044,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1081,27 +1074,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz",
|
||||
"integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/types": "^7.29.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"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==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz",
|
||||
"integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/types": "^7.29.11"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -1135,18 +1128,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"version": "7.29.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz",
|
||||
"integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/generator": "^7.29.13",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/parser": "^7.29.13",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@babel/types": "^7.29.11",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1154,13 +1147,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
|
||||
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-string-parser": "^7.29.11",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7460,18 +7453,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -7484,9 +7477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9581,9 +9574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.12.5",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz",
|
||||
"integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==",
|
||||
"version": "5.12.7",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.7.tgz",
|
||||
"integrity": "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -16587,9 +16580,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"version": "7.30.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.2.tgz",
|
||||
"integrity": "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -18141,6 +18134,13 @@
|
||||
"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.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"version": "4.6.7",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
||||
"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.4",
|
||||
"nodemailer": "^10.1.7",
|
||||
"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.1",
|
||||
"undici": "^7.30.2",
|
||||
"uuid": "^14.0.3",
|
||||
"validator": "^13.15.35",
|
||||
"ws": "^8.22.0"
|
||||
"ws": "^8.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -18343,10 +18343,19 @@
|
||||
"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.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"version": "8.22.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.2.tgz",
|
||||
"integrity": "sha512-/rEZ2ejown2N5gxVTHUu9bVVC7ttu0/Tgzf5hbTOixR3Fs7mbbXgn2DbdbcKgJOmpQMtrIUWivtYuVMnp/W/yA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -125,7 +125,6 @@ describe('core without a teams registration', () => {
|
||||
actor: { user: row },
|
||||
} as never,
|
||||
{ title: 'Puter' } as never,
|
||||
{} as never,
|
||||
);
|
||||
const params = JSON.parse(
|
||||
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
|
||||
|
||||
@@ -167,6 +167,38 @@ 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,6 +172,34 @@ 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 = 98;
|
||||
const CURRENT_SCHEMA_VERSION = 101;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -705,6 +705,65 @@ 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,6 +137,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `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;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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'));
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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`);
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 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,6 +168,204 @@ 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();
|
||||
|
||||
@@ -69,14 +69,50 @@ 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 ------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -111,7 +147,8 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
const { streams: _streams, from: _from, ...transportConf } = emailConf;
|
||||
this.transport = nodemailer.createTransport(transportConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -125,7 +162,8 @@ 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).
|
||||
* message rather than the no-reply From address); `options.stream` is
|
||||
* passed to {@link sendRaw}.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when none is configured —
|
||||
* {@link sendRaw} drops the message instead of throwing. A caller that
|
||||
@@ -135,7 +173,7 @@ export class EmailClient extends PuterClient {
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
options: SendTemplateOptions = {},
|
||||
) {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
@@ -143,12 +181,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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,19 +197,38 @@ 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).
|
||||
* `null` return after). A configured `stream` supplies the from address
|
||||
* when the caller gives none, and headers the caller's own override.
|
||||
*/
|
||||
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:',
|
||||
options,
|
||||
{ ...message, ...(streamName ? { stream: streamName } : {}) },
|
||||
);
|
||||
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({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
...message,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
from: message.from ?? stream?.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
|
||||
@@ -240,6 +241,8 @@ 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.
|
||||
@@ -247,6 +250,12 @@ 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': {
|
||||
@@ -298,6 +307,8 @@ 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.
|
||||
@@ -420,7 +431,11 @@ export type EventMap = {
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
'user.email-changed': {
|
||||
user_id: number;
|
||||
old_email: string | null;
|
||||
new_email: string;
|
||||
};
|
||||
/** An account was suspended, or its suspension lifted. Local events. */
|
||||
'user.suspended': { userId: number };
|
||||
'user.unsuspended': { userId: number };
|
||||
@@ -732,6 +747,8 @@ export type EventMap = {
|
||||
'outer.permission.flatInvalidated': {
|
||||
entries: Array<{ holderUserId: number; permission: string }>;
|
||||
};
|
||||
/** Links on these nodes were removed; read them from the primary. */
|
||||
'outer.share.linksRevoked': { fsentryIds: number[]; uuids: string[] };
|
||||
/**
|
||||
* A user's subscription set changed, so every process must drop its cached
|
||||
* "does this user have any subscriptions" answer. Carries the counter the
|
||||
@@ -870,8 +887,11 @@ export type EventMap = {
|
||||
session_uids: string[];
|
||||
};
|
||||
|
||||
/** One access token was revoked; only its own connections should drop. */
|
||||
'auth.access-token.revoked': { token_uid: string };
|
||||
/**
|
||||
* One access token was revoked; only its own connections should drop.
|
||||
* `outer.*` for the same reason as `outer.auth.sessions.revoked`.
|
||||
*/
|
||||
'outer.auth.access-token.revoked': { token_uid: string };
|
||||
|
||||
/** Same-cluster eviction for raw realtime WebSockets. */
|
||||
'pubsub.realtime.auth-revoked': {
|
||||
@@ -970,6 +990,21 @@ 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;
|
||||
@@ -1079,11 +1114,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
@@ -1094,10 +1128,7 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
|
||||
|
||||
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
|
||||
export type NotifDeliveryOutcome =
|
||||
| 'received'
|
||||
| 'unreceived'
|
||||
| 'no-socket'
|
||||
| 'remote-only';
|
||||
'received' | 'unreceived' | 'no-socket' | 'remote-only';
|
||||
|
||||
/** One socket's receipt for a live notification. */
|
||||
export interface NotifDeliveryResponder {
|
||||
|
||||
@@ -132,6 +132,28 @@ 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,6 +25,8 @@ 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
|
||||
@@ -134,6 +136,8 @@ 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<{
|
||||
@@ -168,6 +172,8 @@ 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;
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -200,6 +202,77 @@ describe('S3Client — remote endpoint configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3Client — bucket in another region', () => {
|
||||
let server: Server;
|
||||
let client: S3Client;
|
||||
let signedRegions: string[];
|
||||
|
||||
// Answers like a bucket that lives in us-west-2: a request signed for any
|
||||
// other region gets S3's 301 naming the bucket's region.
|
||||
beforeEach(async () => {
|
||||
signedRegions = [];
|
||||
server = createServer((req, res) => {
|
||||
const region =
|
||||
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
|
||||
req.headers.authorization ?? '',
|
||||
)?.[1] ?? '';
|
||||
signedRegions.push(region);
|
||||
if (region !== 'us-west-2') {
|
||||
res.writeHead(301, {
|
||||
'content-type': 'application/xml',
|
||||
'x-amz-bucket-region': 'us-west-2',
|
||||
});
|
||||
res.end(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/plain',
|
||||
'content-length': '5',
|
||||
});
|
||||
res.end('hello');
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
client = new S3Client({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
s3: {
|
||||
s3Config: {
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
accessKeyId: 'fakeAccessKeyId',
|
||||
secretAccessKey: 'fakeSecretAccessKey',
|
||||
forcePathStyle: true,
|
||||
},
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
await client.onServerStart();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.onServerShutdown();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("retries a read in the bucket's region when S3 redirects it", async () => {
|
||||
const result = await client
|
||||
.get('ap-northeast-2')
|
||||
.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'other-bucket',
|
||||
Key: 'index.html',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await result.Body?.transformToString()).toBe('hello');
|
||||
expect(signedRegions).toEqual(['ap-northeast-2', 'us-west-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3Client — legacy storage migration', () => {
|
||||
let client: S3Client;
|
||||
let dir: string;
|
||||
|
||||
@@ -235,6 +235,9 @@ export class S3Client extends PuterClient {
|
||||
|
||||
const client = new AwsS3Client({
|
||||
region,
|
||||
// Rows without a stored region are read with the server's own, so
|
||||
// retry where S3's 301 says the bucket actually lives.
|
||||
followRegionRedirects: true,
|
||||
requestStreamBufferSize: 32 * 1024,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
socketTimeout: 5000,
|
||||
|
||||
@@ -52,8 +52,8 @@ const APP_READ_LIMIT = {
|
||||
/**
|
||||
* Unauthenticated icon serving; no actor to key on, so the bucket is the
|
||||
* address — and an address is a NAT, a campus or a carrier gateway that can
|
||||
* hold hundreds of desktops. Each desktop boot pulls the taskbar's icons at
|
||||
* once, so a single burst from one network is already thousands of requests.
|
||||
* hold hundreds of desktops. Each dashboard load pulls its app icons at once,
|
||||
* so a single burst from one network is already thousands of requests.
|
||||
* Responses are publicly cacheable and usually a redirect, so the ceiling is
|
||||
* not protecting bandwidth; it is there so a client looping on a broken icon
|
||||
* can't spin unbounded.
|
||||
@@ -523,7 +523,7 @@ export class AppController extends PuterController {
|
||||
};
|
||||
|
||||
// Icons are <img src> targets from the GUI (root) AND resolved via
|
||||
// api_base_url in taskbar payloads. Register on both so either origin
|
||||
// api_base_url in app summaries. Register on both so either origin
|
||||
// works without a cross-subdomain redirect.
|
||||
router.get(
|
||||
'/app-icon/:app_uid',
|
||||
|
||||
@@ -1271,7 +1271,7 @@ describe('AppController app-icon rate limit', () => {
|
||||
it('sizes the icon bucket for a whole network rather than one desktop', () => {
|
||||
// Icons are unauthenticated `<img src>` targets, so the only key is
|
||||
// the address — which one NAT shares across every desktop behind it,
|
||||
// and each boot pulls the taskbar's icons in a burst.
|
||||
// and each dashboard load pulls its app icons in a burst.
|
||||
for (const path of ['/app-icon/:app_uid', '/app-icon/:app_uid/:size']) {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === path,
|
||||
|
||||
@@ -23,8 +23,10 @@ import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||||
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';
|
||||
@@ -350,6 +352,98 @@ 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.
|
||||
@@ -393,7 +487,10 @@ 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);
|
||||
@@ -559,7 +656,6 @@ describe('2FA enrollment over HTTP', () => {
|
||||
expect(enabled.status).toBe(200);
|
||||
expect((await otpState(account.userId)).enabled).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -714,4 +810,67 @@ describe('session token helpers over HTTP', () => {
|
||||
'puter_auth_token=',
|
||||
);
|
||||
});
|
||||
|
||||
describe("the shell's guiTokenDigest", () => {
|
||||
/** The session cookie `/session/sync-cookie` sets for `token`. */
|
||||
const sessionCookie = async (token: string) => {
|
||||
const sync = await get('/session/sync-cookie', token);
|
||||
expect(sync.status).toBe(204);
|
||||
const value = /puter_auth_token=([^;]+)/.exec(
|
||||
sync.headers.get('set-cookie') ?? '',
|
||||
)?.[1];
|
||||
expect(value).toBeTruthy();
|
||||
return `puter_auth_token=${value}`;
|
||||
};
|
||||
|
||||
/** What the shell at `path` renders for `guiTokenDigest`. */
|
||||
const shellDigest = async (path: string, cookie?: string) => {
|
||||
const res = await fetch(new URL(path, env.origin), {
|
||||
headers: cookie ? { cookie } : {},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const html = await res.text();
|
||||
const match = /"guiTokenDigest":(null|"[0-9a-f]{32}")/.exec(html);
|
||||
if (!match) {
|
||||
expect(html).not.toContain('guiTokenDigest');
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(match[1]) as string | null;
|
||||
};
|
||||
|
||||
it("names the token /get-gui-token hands over for the session cookie", async () => {
|
||||
const { token } = await signupTemp();
|
||||
const cookie = await sessionCookie(token);
|
||||
|
||||
const gui = await fetch(new URL('/get-gui-token', env.origin), {
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(gui.status).toBe(200);
|
||||
const { token: guiToken } = (await gui.json()) as { token: string };
|
||||
|
||||
const digest = await shellDigest('/', cookie);
|
||||
expect(digest).toBe(await tokenClaimsDigest(guiToken));
|
||||
// The token the browser stored at sign-up is for the same session.
|
||||
expect(await tokenClaimsDigest(token)).toBe(digest);
|
||||
});
|
||||
|
||||
it('is null without a session cookie, even with a token in the URL', async () => {
|
||||
const { token } = await signupTemp();
|
||||
expect(await shellDigest('/')).toBe(null);
|
||||
expect(
|
||||
await shellDigest(`/?auth_token=${encodeURIComponent(token)}`),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('is left out when the session cookie does not resolve to a session', async () => {
|
||||
const { token } = await signupTemp();
|
||||
const cookie = await sessionCookie(token);
|
||||
await env.server.services.auth.revokeSession(claims(token).session_uid);
|
||||
|
||||
expect(await shellDigest('/', cookie)).toBe(undefined);
|
||||
expect(await shellDigest('/', 'puter_auth_token=garbage')).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
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';
|
||||
@@ -721,6 +722,36 @@ 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;
|
||||
@@ -1416,7 +1447,7 @@ describe('AuthController.handleLogin', () => {
|
||||
is_user_token: true,
|
||||
});
|
||||
expect(body.user.directories).toBeTypeOf('object');
|
||||
expect(Array.isArray(body.user.taskbar_items)).toBe(true);
|
||||
expect(body.user).not.toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
// A deleted row still answering from cache reaches the session INSERT.
|
||||
@@ -4765,14 +4796,24 @@ describe('AuthController.handleSendConfirmEmail', () => {
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rotates the email_confirm_code and returns {} on success', async () => {
|
||||
it('rotates the email_confirm_code and reports the send', 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();
|
||||
await controller.handleSendConfirmEmail(makeReq({}, { actor }), res);
|
||||
expect(res.body).toEqual({});
|
||||
try {
|
||||
await controller.handleSendConfirmEmail(
|
||||
makeReq({}, { actor }),
|
||||
res,
|
||||
);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
expect(res.body).toEqual({ sent: true });
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
});
|
||||
@@ -4782,7 +4823,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 returns {} — when the resend send throws', async () => {
|
||||
it('alarms, and still answers 200 with sent: false, when the resend send throws', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const alarm = vi
|
||||
.spyOn(server.clients.alarm, 'create')
|
||||
@@ -4797,7 +4838,8 @@ describe('AuthController.handleSendConfirmEmail', () => {
|
||||
res,
|
||||
);
|
||||
// Not the caller's fault; still a 200.
|
||||
expect(res.body).toEqual({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toEqual({ sent: false });
|
||||
|
||||
const raised = alarm.mock.calls.find(
|
||||
(c) => c[0] === 'auth:confirmation-email-send-failed:resend',
|
||||
@@ -7468,6 +7510,11 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
|
||||
const heard: Array<Record<string, unknown>> = [];
|
||||
eventClient.on('user.email-changed', (_k, data) => {
|
||||
heard.push(data as Record<string, unknown>);
|
||||
});
|
||||
|
||||
const req = makeReq({});
|
||||
(req as unknown as { query: Record<string, string> }).query = {
|
||||
token: linkJwt,
|
||||
@@ -7475,6 +7522,11 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
const res = makeRes();
|
||||
await controller.handleChangeEmailConfirm(req, res);
|
||||
expect(res.sent).toMatch(/Email changed successfully/);
|
||||
expect(heard).toContainEqual({
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
});
|
||||
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
@@ -7508,6 +7560,46 @@ 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(
|
||||
@@ -8283,11 +8375,10 @@ describe('AuthController session endpoints', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('revoke-session: 403 when revoking someone else’s session', async () => {
|
||||
it('revoke-session: 404 when revoking someone else’s session', async () => {
|
||||
const { user: u1 } = await makeUserAndActor();
|
||||
const { actor: a2 } = await makeUserAndActor();
|
||||
// Create a real session for u1 so the lookup succeeds, then attempt
|
||||
// to revoke it as a2 — must 403.
|
||||
// A real session of u1, revoked as a2: answers as a missing one.
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
u1,
|
||||
{},
|
||||
@@ -8300,7 +8391,7 @@ describe('AuthController session endpoints', () => {
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rename-session: 400 when uuid param is missing', async () => {
|
||||
@@ -9500,14 +9591,18 @@ 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',
|
||||
}),
|
||||
makeRes(),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
confirmation_email_sent: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9528,6 +9623,93 @@ 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(
|
||||
@@ -10072,6 +10254,77 @@ describe('AuthController.handleRevokeSession additional branches', () => {
|
||||
expect(Array.isArray(body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
const expiredSessionOf = async (user: { id: number }) => {
|
||||
const row = await server.stores.session.create(user.id, {
|
||||
expires_at: Math.floor(Date.now() / 1000) - 1,
|
||||
});
|
||||
return row.uuid as string;
|
||||
};
|
||||
|
||||
it('revokes an expired session the caller owns', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const uuid = await expiredSessionOf(user);
|
||||
const res = makeRes();
|
||||
await controller.handleRevokeSession(makeReq({ uuid }, { actor }), res);
|
||||
|
||||
const body = res.body as { sessions: Array<{ uuid: string }> };
|
||||
expect(body.sessions.map((s) => s.uuid)).not.toContain(uuid);
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `revoked_at` FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
);
|
||||
expect(rows[0].revoked_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('revoking an expired session ends its live child token', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const parent = await expiredSessionOf(user);
|
||||
const child = await server.stores.session.getOrCreateGodmodeToken(
|
||||
user.id,
|
||||
{
|
||||
parentSessionUid: parent,
|
||||
appUid: `app-${uuidv4()}`,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
maxAgeSeconds: 3600,
|
||||
label: 'Godmode app',
|
||||
auth_id: user.uuid,
|
||||
},
|
||||
);
|
||||
const childUuid = child!.row.uuid as string;
|
||||
expect(await server.stores.session.getByUuid(childUuid)).toBeTruthy();
|
||||
|
||||
await controller.handleRevokeSession(
|
||||
makeReq({ uuid: parent }, { actor }),
|
||||
makeRes(),
|
||||
);
|
||||
|
||||
expect(await server.stores.session.getByUuid(childUuid)).toBeNull();
|
||||
});
|
||||
|
||||
it('answers 404 for an expired session of another user', async () => {
|
||||
const { user: owner } = await makeUserAndActor();
|
||||
const { actor } = await makeUserAndActor();
|
||||
const uuid = await expiredSessionOf(owner);
|
||||
await expect(
|
||||
controller.handleRevokeSession(
|
||||
makeReq({ uuid }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('answers 404 for a session that is already revoked', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const row = await server.stores.session.create(user.id);
|
||||
await server.stores.session.removeByUuid(row.uuid);
|
||||
await expect(
|
||||
controller.handleRevokeSession(
|
||||
makeReq({ uuid: row.uuid }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('refuses to revoke the caller’s OWN current session row (400)', async () => {
|
||||
// Invariant: a self-revoke leaves the client in an ambiguous
|
||||
// identity state because the response can't write fresh auth
|
||||
@@ -10552,10 +10805,10 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
// party holding the session id is somewhere else entirely.
|
||||
const token = await mintAppToken(actor, OPENER);
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: 'https://evil.test' },
|
||||
token,
|
||||
const session = uuidv4();
|
||||
const [{ res, outcome }] = await runWaits(
|
||||
[{ session, origin: 'https://evil.test' }],
|
||||
{ during: () => setToken(session, token) },
|
||||
);
|
||||
// Same 408 the empty path returns — a mismatched caller must not be
|
||||
// able to tell "nothing arrived" from "something arrived for someone
|
||||
@@ -10631,10 +10884,10 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
);
|
||||
const guiToken = (loginRes.body as { token: string }).token;
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: OPENER },
|
||||
guiToken,
|
||||
const session = uuidv4();
|
||||
const [{ res, outcome }] = await runWaits(
|
||||
[{ session, origin: OPENER }],
|
||||
{ during: () => setToken(session, guiToken) },
|
||||
);
|
||||
expect(outcome).toMatchObject({ statusCode: 408 });
|
||||
expect(res.body).toBeUndefined();
|
||||
@@ -10649,14 +10902,406 @@ describe('AuthController.loginWait audience binding', () => {
|
||||
{ keyid: 'v2' },
|
||||
);
|
||||
|
||||
const { res, outcome } = await waitWithRelay(
|
||||
uuidv4(),
|
||||
{ origin: OPENER },
|
||||
forged,
|
||||
const session = uuidv4();
|
||||
const [{ res, outcome }] = await runWaits(
|
||||
[{ session, origin: OPENER }],
|
||||
{ during: () => setToken(session, 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 -----------------------
|
||||
@@ -11013,3 +11658,100 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ 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,
|
||||
@@ -143,6 +144,12 @@ 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
|
||||
@@ -345,36 +352,43 @@ export class AuthController extends PuterController {
|
||||
const expectedAppUid =
|
||||
await this.services.auth.appUidFromOrigin(reqOrigin);
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
const relayKey = loginRelayKey(session, expectedAppUid);
|
||||
const { resolve, promise } = Promise.withResolvers<string>();
|
||||
|
||||
// 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. 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.
|
||||
// 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) {
|
||||
throw new HttpError(408, 'Request timeout.', {
|
||||
legacyCode: 'request_timeout',
|
||||
});
|
||||
@@ -386,24 +400,46 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
#tokenIsForApp(token: string, expectedAppUid: string): boolean {
|
||||
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 {
|
||||
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 === expectedAppUid
|
||||
);
|
||||
return payload.type === 'app-under-user' && payload.app_uid
|
||||
? payload.app_uid
|
||||
: null;
|
||||
} catch {
|
||||
// Malformed, expired, or signed with a key we don't hold.
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@Post('/login/set', {
|
||||
@@ -418,12 +454,34 @@ export class AuthController extends PuterController {
|
||||
})
|
||||
async loginSet(req: Request, res: Response) {
|
||||
const { session, auth_token } = req.body ?? {};
|
||||
if (!session || !auth_token || !validateUuid(session)) {
|
||||
if (
|
||||
!session ||
|
||||
typeof auth_token !== 'string' ||
|
||||
!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}`,
|
||||
{
|
||||
@@ -882,14 +940,19 @@ export class AuthController extends PuterController {
|
||||
if (!bonusCode) throw bonusCodeInvalidError();
|
||||
}
|
||||
|
||||
const user = await this.services.signup.signup({
|
||||
req,
|
||||
body,
|
||||
isTemp: is_temp,
|
||||
fingerprint,
|
||||
bonusCode,
|
||||
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 }
|
||||
: {}),
|
||||
});
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1028,10 +1091,12 @@ export class AuthController extends PuterController {
|
||||
email_confirm_code: code,
|
||||
});
|
||||
|
||||
await this.services.signup.sendConfirmationEmail(user, 'resend', {
|
||||
code,
|
||||
});
|
||||
res.json({});
|
||||
const sent = await this.services.signup.sendConfirmationEmail(
|
||||
user,
|
||||
'resend',
|
||||
{ code },
|
||||
);
|
||||
res.json({ sent });
|
||||
}
|
||||
|
||||
@Post('/confirm-email', {
|
||||
@@ -1445,6 +1510,7 @@ export class AuthController extends PuterController {
|
||||
ip,
|
||||
device_id: req.deviceFingerprint ?? undefined,
|
||||
user_agent: userAgent,
|
||||
tls_fingerprint: req.tlsFingerprint,
|
||||
dispatch_id: dispatchId,
|
||||
},
|
||||
);
|
||||
@@ -2019,6 +2085,7 @@ export class AuthController extends PuterController {
|
||||
user.email,
|
||||
'email_password_recovery',
|
||||
{ link },
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[send-pass-recovery-email] send failed:', e);
|
||||
@@ -2282,6 +2349,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
username: user.username,
|
||||
},
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[change-password] notification send failed:', e);
|
||||
@@ -2507,6 +2575,7 @@ export class AuthController extends PuterController {
|
||||
new_email,
|
||||
'email_verification_link',
|
||||
{ link },
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[change-email] new-address email failed:', e);
|
||||
@@ -2514,15 +2583,8 @@ export class AuthController extends PuterController {
|
||||
// Notify the old address too
|
||||
if (user.email) {
|
||||
try {
|
||||
await (
|
||||
this.clients.email as unknown as {
|
||||
sendRaw: (opts: {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).sendRaw({
|
||||
await this.clients.email.sendRaw({
|
||||
stream: 'auth',
|
||||
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.`,
|
||||
@@ -2647,6 +2709,7 @@ export class AuthController extends PuterController {
|
||||
'user.email-changed' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
} as never,
|
||||
{},
|
||||
@@ -2810,18 +2873,11 @@ export class AuthController extends PuterController {
|
||||
username,
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
await this.services.signup.sendConfirmationEmail(
|
||||
{ ...user, username, email },
|
||||
'save-account',
|
||||
{ code: email_confirm_code },
|
||||
);
|
||||
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
@@ -3584,21 +3640,18 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// `getByUuid` returns null when the row is missing, already
|
||||
// soft-revoked, or past `expires_at` — surface as 404 so a stale
|
||||
// manage-sessions UI doesn't 500 when it clicks revoke on a row
|
||||
// that already went away.
|
||||
const session = await this.stores.session.getByUuid(uuid);
|
||||
if (!session) {
|
||||
// Missing, already revoked and other users' rows all answer 404; an
|
||||
// expired row is still revoked so its children go with it.
|
||||
const session = await this.stores.session.getByUuidAny(uuid);
|
||||
if (
|
||||
!session ||
|
||||
session.revoked_at != null ||
|
||||
session.user_id !== req.actor!.user.id
|
||||
) {
|
||||
throw new HttpError(404, 'Session not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
if (session.user_id !== req.actor!.user.id) {
|
||||
throw new HttpError(403, 'Can only revoke your own sessions', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
await this.services.auth.revokeSession(uuid);
|
||||
const sessions = await this.services.auth.listSessions(req.actor!);
|
||||
res.json({ sessions });
|
||||
@@ -4274,9 +4327,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (this.clients.email && user.email) {
|
||||
try {
|
||||
await this.clients.email.send(user.email, 'enabled_2fa', {
|
||||
username: user.username,
|
||||
});
|
||||
await this.clients.email.send(
|
||||
user.email,
|
||||
'enabled_2fa',
|
||||
{ username: user.username },
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[configure-2fa] email send failed:', e);
|
||||
}
|
||||
@@ -4350,9 +4406,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (this.clients.email && user.email) {
|
||||
try {
|
||||
await this.clients.email.send(user.email, 'disabled_2fa', {
|
||||
username: user.username,
|
||||
});
|
||||
await this.clients.email.send(
|
||||
user.email,
|
||||
'disabled_2fa',
|
||||
{ username: user.username },
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[disable-2fa] email send failed:', e);
|
||||
}
|
||||
@@ -4428,6 +4487,34 @@ 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
|
||||
@@ -4452,6 +4539,14 @@ 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,
|
||||
@@ -4768,6 +4863,7 @@ export class AuthController extends PuterController {
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
oidcContinuation?: unknown,
|
||||
extra: { confirmation_email_sent?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const redirectUrl =
|
||||
oidcContinuation === undefined
|
||||
@@ -4802,6 +4898,7 @@ export class AuthController extends PuterController {
|
||||
token: gui_token,
|
||||
user: details,
|
||||
...(redirectUrl ? { redirectUrl } : {}),
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,253 +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 { 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'];
|
||||
|
||||
/**
|
||||
* Desktop/UI preference routes.
|
||||
*
|
||||
* Two categories:
|
||||
*
|
||||
* - User-level: desktop background, taskbar items (UserStore)
|
||||
* - Folder-level: layout, sort_by/sort_order (fsentries table)
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — background, taskbar, layout, sort order. All four
|
||||
* persist to the user row 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) {
|
||||
// -- Desktop background --------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set-desktop-bg',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { url, color, fit } = req.body ?? {};
|
||||
|
||||
const patch = {};
|
||||
if (url !== undefined) {
|
||||
if (url !== null && typeof url !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`url` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_url = url;
|
||||
}
|
||||
if (color !== undefined) {
|
||||
if (color !== null && typeof color !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`color` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_color = color;
|
||||
}
|
||||
if (fit !== undefined) {
|
||||
if (fit !== null && typeof fit !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`fit` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_fit = fit;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new HttpError(400, 'No fields provided', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor.user.id, patch);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Taskbar items -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/update-taskbar-items',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { items } = req.body ?? {};
|
||||
if (!Array.isArray(items)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Missing or invalid `items` array',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor.user.id, {
|
||||
taskbar_items: JSON.stringify(items),
|
||||
});
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- 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() {}
|
||||
}
|
||||
@@ -1,511 +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 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');
|
||||
});
|
||||
};
|
||||
|
||||
// ── /set-desktop-bg ─────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set-desktop-bg', () => {
|
||||
it('persists url/color/fit on the user row', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: {
|
||||
url: 'https://cdn.test/wallpaper.png',
|
||||
color: '#000000',
|
||||
fit: 'cover',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_url).toBe(
|
||||
'https://cdn.test/wallpaper.png',
|
||||
);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#000000');
|
||||
expect(refreshed?.desktop_bg_fit).toBe('cover');
|
||||
});
|
||||
|
||||
it('persists only the supplied fields (partial update)', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { color: '#ffffff' }, actor }),
|
||||
res,
|
||||
);
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
|
||||
// Untouched fields remain at their column default.
|
||||
expect(refreshed?.desktop_bg_url).toBeFalsy();
|
||||
expect(refreshed?.desktop_bg_fit).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes through `null` to clear a field', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
// First populate, then clear.
|
||||
const populate = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: { url: 'https://cdn.test/x.png' },
|
||||
actor,
|
||||
}),
|
||||
populate.res,
|
||||
);
|
||||
const before = await server.stores.user.getById(userId);
|
||||
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
|
||||
|
||||
const clear = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: null }, actor }),
|
||||
clear.res,
|
||||
);
|
||||
const after = await server.stores.user.getById(userId);
|
||||
expect(after?.desktop_bg_url).toBeNull();
|
||||
});
|
||||
|
||||
it('throws 400 when url is not a string or null', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: 123 }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when no fields are provided', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /update-taskbar-items ───────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /update-taskbar-items', () => {
|
||||
it('persists items as a JSON-encoded string', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const items = [{ name: 'editor' }, { name: 'browser' }];
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: { items }, actor }),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.taskbar_items).toBe(JSON.stringify(items));
|
||||
});
|
||||
|
||||
it('throws 400 when items is missing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when items is not an array', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/update-taskbar-items',
|
||||
makeReq({ body: { items: 'oops' }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /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 });
|
||||
});
|
||||
});
|
||||
@@ -501,6 +501,29 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
expect(body.service.name).toBe('puter-kvstore');
|
||||
});
|
||||
|
||||
it('treats null `args` as no arguments', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-subdomains:ii:puter-subdomains',
|
||||
{
|
||||
permission: 'service:puter-subdomains:ii:puter-subdomains',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq(
|
||||
{ interface: 'puter-subdomains', method: 'select', args: null },
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](req, res as unknown as Response, () => {}),
|
||||
);
|
||||
expect((res.body as { success: boolean }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('pipes a stream-shaped result instead of JSON', async () => {
|
||||
// Use an ephemeral driver registered onto the controller's bag.
|
||||
const streamPayload = Buffer.from('hello-world');
|
||||
|
||||
@@ -276,8 +276,10 @@ export class DriverController extends PuterController {
|
||||
interface: ifaceName,
|
||||
method,
|
||||
driver: driverName,
|
||||
args = {},
|
||||
args: rawArgs,
|
||||
} = (req.body ?? {}) as Record<string, unknown>;
|
||||
// Drivers read fields off `args`, so a null one counts as absent.
|
||||
const args = rawArgs ?? {};
|
||||
|
||||
if (!ifaceName || typeof ifaceName !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `interface`', {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -46,7 +47,7 @@ export class CallSlot {
|
||||
this.#maybeRelease();
|
||||
};
|
||||
res.once('finish', responded);
|
||||
res.once('close', responded);
|
||||
onResponseClose(res, responded);
|
||||
if (res.destroyed || res.writableFinished) responded();
|
||||
if (!this.#released && this.#work.size > 0) this.#startRenewing();
|
||||
}
|
||||
|
||||
@@ -154,6 +154,26 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
).toEqual(['a', 'a/b']);
|
||||
});
|
||||
|
||||
it('answers 404 for a directory whose parent chain is broken', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const path = `/${username}/Documents/http-orphan-${Date.now()}`;
|
||||
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, auth_token: token }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
const { uid } = (await created.json()) as { uid: string };
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE fsentries SET path = NULL, parent_uid = ? WHERE uuid = ?',
|
||||
['does-not-exist', uid],
|
||||
);
|
||||
await env.server.stores.fsEntry.invalidateEntryCacheByUuid(uid);
|
||||
|
||||
const response = await fetch(readdirUrl({ uid, auth_token: token }));
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('mkdir does not return internal ids, storage columns or tokens', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
||||
|
||||
@@ -1850,7 +1850,9 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(targetPath, {
|
||||
foldedNames: true,
|
||||
});
|
||||
if (!existing) return;
|
||||
const parent = pathPosix.dirname(targetPath);
|
||||
await this.#assertAccess(
|
||||
|
||||
@@ -168,6 +168,27 @@ 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']) {
|
||||
|
||||
@@ -5111,9 +5111,8 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
// ── GET /get-launch-apps ────────────────────────────────────────────
|
||||
//
|
||||
// `recent` is a launch-metadata producer: it returns `index_url` for
|
||||
// apps the user has opened before. The taskbar happens to launch these
|
||||
// by name (so AppDriver's hosted-backing guard applies), but the field
|
||||
// is served regardless, so the guard is applied here too.
|
||||
// apps the user has opened before. The field is served whoever reads it,
|
||||
// so AppDriver's hosted-backing guard is applied here too.
|
||||
|
||||
describe('LegacyFSController GET /get-launch-apps', () => {
|
||||
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
|
||||
|
||||
@@ -401,11 +401,9 @@ export class LegacyFSController extends PuterController {
|
||||
.filter(Boolean);
|
||||
|
||||
// Don't hand out an index_url whose puter-hosted backing is
|
||||
// gone or reclaimed. The taskbar launches recents by name (so
|
||||
// AppDriver's guard applies), but this list is a
|
||||
// launch-metadata producer like any other — a future consumer
|
||||
// reading index_url straight off it shouldn't inherit a stale
|
||||
// origin.
|
||||
// gone or reclaimed. This list is a launch-metadata producer
|
||||
// like any other — a consumer reading index_url straight off
|
||||
// it shouldn't inherit a stale origin.
|
||||
let backingGoneFlags: boolean[];
|
||||
try {
|
||||
backingGoneFlags =
|
||||
@@ -767,8 +765,10 @@ export class LegacyFSController extends PuterController {
|
||||
normalizedTarget,
|
||||
);
|
||||
if (dedupeName) {
|
||||
const existing =
|
||||
await this.stores.fsEntry.getEntryByPath(normalizedTarget);
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(
|
||||
normalizedTarget,
|
||||
{ foldedNames: true },
|
||||
);
|
||||
if (existing) {
|
||||
const parent = pathPosix.dirname(normalizedTarget);
|
||||
await assertAccess(
|
||||
@@ -950,9 +950,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
|
||||
const oldPath = source.path;
|
||||
@@ -1324,7 +1322,7 @@ export class LegacyFSController extends PuterController {
|
||||
if (options.realMime) {
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
contentTypeFromMime(entry.name) as string,
|
||||
contentTypeFromMime(entry.name) || 'application/octet-stream',
|
||||
);
|
||||
} else {
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
@@ -1426,8 +1424,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -2054,10 +2051,7 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
|
||||
@@ -277,4 +277,124 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
contentType?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
@@ -87,6 +88,7 @@ const makeReq = (init: {
|
||||
actor?: Actor;
|
||||
hostname?: string;
|
||||
protocol?: string;
|
||||
originalUrl?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
body: {},
|
||||
@@ -94,6 +96,7 @@ const makeReq = (init: {
|
||||
headers: {},
|
||||
params: init.params ?? {},
|
||||
path: init.path ?? '/',
|
||||
originalUrl: init.originalUrl ?? init.path ?? '/',
|
||||
hostname: init.hostname ?? 'test.local',
|
||||
protocol: init.protocol ?? 'http',
|
||||
actor: init.actor,
|
||||
@@ -131,6 +134,11 @@ const makeRes = () => {
|
||||
captured.contentType = value;
|
||||
return res;
|
||||
}),
|
||||
redirect: vi.fn((location: string) => {
|
||||
captured.statusCode = 302;
|
||||
captured.location = location;
|
||||
return res;
|
||||
}),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
@@ -214,23 +222,72 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
|
||||
for (const path of [
|
||||
'/settings',
|
||||
'/settings/*splat',
|
||||
'/dashboard',
|
||||
'/dashboard/',
|
||||
'/desktop',
|
||||
'/desktop/',
|
||||
'/action/*splat',
|
||||
'/@:username',
|
||||
]) {
|
||||
it('leaves guiTokenDigest out for a session cookie that is not a user session', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const appActor = {
|
||||
...actor,
|
||||
app: { id: 1, uid: 'app-test' },
|
||||
session: { uid: uuidv4() },
|
||||
} as unknown as Actor;
|
||||
const req = Object.assign(makeReq({ path: '/', actor: appActor }), {
|
||||
tokenSource: 'cookie',
|
||||
cookies: { puter_token_v2: 'legacy' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute('get', '/', req, res);
|
||||
expect(String(captured.body)).not.toContain('guiTokenDigest');
|
||||
});
|
||||
|
||||
it('serves the shell on /dashboard and /action', async () => {
|
||||
for (const path of ['/dashboard', '/dashboard/', '/action/*splat']) {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute('get', path, makeReq({ path }), res);
|
||||
expect(typeof captured.body).toBe('string');
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects /desktop to the root, keeping the query', async () => {
|
||||
for (const path of ['/desktop', '/desktop/']) {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
path,
|
||||
makeReq({ path, originalUrl: `${path}?ref=abc&x=1` }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/?ref=abc&x=1');
|
||||
expect(captured.body).toBeUndefined();
|
||||
}
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute('get', '/desktop', makeReq({ path: '/desktop' }), res);
|
||||
expect(captured.location).toBe('/');
|
||||
});
|
||||
|
||||
it('redirects /settings to the dashboard Home tab', async () => {
|
||||
for (const path of ['/settings', '/settings/*splat']) {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute('get', path, makeReq({ path }), res);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/dashboard#home');
|
||||
expect(captured.body).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('redirects /@:username to the dashboard', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/@:username',
|
||||
makeReq({ path: '/@alice', params: { username: 'alice' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/');
|
||||
expect(captured.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── /app/:name ──────────────────────────────────────────────────────
|
||||
@@ -360,47 +417,21 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).not.toContain('owner_user_id');
|
||||
});
|
||||
|
||||
it('serves the same app shell under /desktop/app/:name', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Desktop App',
|
||||
description: 'opens on the desktop instead of the dashboard',
|
||||
index_url: `https://example.com/${name}/`,
|
||||
approved_for_listing: 1,
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop/app/:name',
|
||||
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(200);
|
||||
const html = String(captured.body);
|
||||
expect(html).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(html).toContain('Desktop App');
|
||||
});
|
||||
|
||||
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
|
||||
it('redirects /desktop/app/:name to /app/:name, keeping the query', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/desktop/app/:name',
|
||||
makeReq({
|
||||
params: { name: 'no-such-app' },
|
||||
path: '/desktop/app/no-such-app',
|
||||
params: { name: 'editor' },
|
||||
path: '/desktop/app/editor',
|
||||
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt&x=1',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(404);
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt&x=1');
|
||||
expect(captured.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
|
||||
@@ -434,40 +465,39 @@ describe('HomepageController GET /app/:name', () => {
|
||||
// -- app landing rate limit --
|
||||
|
||||
describe('HomepageController app landing rate limit', () => {
|
||||
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
|
||||
for (const path of ['/app/:name', '/desktop/app/:name']) {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No GET ${path} route`);
|
||||
expect(route.options.rateLimit).toEqual({
|
||||
scope: 'app-landing',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
});
|
||||
}
|
||||
it('rate-limits /app/:name per network', () => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === 'get' && r.path === '/app/:name',
|
||||
);
|
||||
if (!route) throw new Error('No GET /app/:name route');
|
||||
expect(route.options.rateLimit).toEqual({
|
||||
scope: 'app-landing',
|
||||
limit: 600,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('HomepageController GET /show/*splat', () => {
|
||||
it('emits a launch_app explorer call with the post-/show path', async () => {
|
||||
it('redirects to the path in the dashboard Files tab, keeping the query', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/show/*splat',
|
||||
makeReq({ path: '/show/alice/Documents' }),
|
||||
makeReq({
|
||||
path: '/show/alice/My%20Documents',
|
||||
originalUrl: '/show/alice/My%20Documents?ref=abc',
|
||||
}),
|
||||
res,
|
||||
);
|
||||
// The launch payload is JSON-serialized into the rendered HTML.
|
||||
// We assert the rendered shell included the explorer launch hint
|
||||
// pointing at the expected (slashed) path.
|
||||
const html = String(captured.body);
|
||||
expect(html).toContain('launch_app');
|
||||
expect(html).toContain('explorer');
|
||||
expect(html).toContain('/alice/Documents');
|
||||
expect(captured.statusCode).toBe(302);
|
||||
expect(captured.location).toBe(
|
||||
'/dashboard?ref=abc#files/alice/My%20Documents',
|
||||
);
|
||||
expect(captured.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { PuterController } from '../types.js';
|
||||
import { isPlainUserActor } from '../../core/actor.js';
|
||||
import { toAppShellView } from '../../util/appShellView.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter';
|
||||
import {
|
||||
@@ -27,17 +28,19 @@ import {
|
||||
subdomainLabelOf,
|
||||
} from '../../core/http/middleware/hostRedirects.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type {
|
||||
PuterHomepageService,
|
||||
PageMeta,
|
||||
LaunchOptions,
|
||||
PopupFrame,
|
||||
ShellContext,
|
||||
} from '../../services/homepage/PuterHomepageService';
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window, and the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab.
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade and
|
||||
* top-up dialogs, which it would otherwise open in a dashboard tab, and the
|
||||
* email confirmation, which it otherwise can't show.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
@@ -45,6 +48,9 @@ export const FRAMED_POPUP_ACTIONS = [
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
'attach-custom-domain',
|
||||
] as const;
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
@@ -166,7 +172,6 @@ export class HomepageController extends PuterController {
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
metaOverrides: Partial<PageMeta> = {},
|
||||
launch: LaunchOptions = {},
|
||||
popupFrame?: PopupFrame,
|
||||
) => {
|
||||
const meta = { ...defaultMeta(req), ...metaOverrides };
|
||||
@@ -176,7 +181,16 @@ export class HomepageController extends PuterController {
|
||||
actor?: Parameters<typeof homepage.send>[0]['actor'];
|
||||
}
|
||||
).actor ?? null;
|
||||
await homepage.send({ req, res, actor }, meta, launch, popupFrame);
|
||||
await homepage.send(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
actor,
|
||||
guiTokenDigest: this.#guiTokenDigest(req, actor),
|
||||
},
|
||||
meta,
|
||||
popupFrame,
|
||||
);
|
||||
};
|
||||
|
||||
// -- Root + path-aliased shell routes ------------------------
|
||||
@@ -248,7 +262,6 @@ export class HomepageController extends PuterController {
|
||||
req,
|
||||
res,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
@@ -259,23 +272,42 @@ export class HomepageController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
|
||||
// Old settings links land on the dashboard's Home tab.
|
||||
router.get('/settings', {}, (_req, res) =>
|
||||
res.redirect('/dashboard#home'),
|
||||
);
|
||||
router.get('/settings/*splat', {}, (_req, res) =>
|
||||
res.redirect('/dashboard#home'),
|
||||
);
|
||||
|
||||
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/desktop', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
|
||||
const queryOf = (req: express.Request) => {
|
||||
const separator = req.originalUrl.indexOf('?');
|
||||
return separator === -1 ? '' : req.originalUrl.slice(separator);
|
||||
};
|
||||
|
||||
// Old desktop links land on the dashboard, query intact.
|
||||
router.get('/desktop', {}, (req, res) =>
|
||||
res.redirect(`/${queryOf(req)}`),
|
||||
);
|
||||
router.get('/desktop/', {}, (req, res) =>
|
||||
res.redirect(`/${queryOf(req)}`),
|
||||
);
|
||||
|
||||
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
// Old links to a user's public folder land on the dashboard.
|
||||
router.get('/@:username', {}, (_req, res) => res.redirect('/'));
|
||||
|
||||
// Old desktop app links open the app in the dashboard, query intact.
|
||||
router.get('/desktop/app/:name', {}, (req, res) => {
|
||||
const name = encodeURIComponent(String(req.params.name ?? ''));
|
||||
res.redirect(`/app/${name}${queryOf(req)}`);
|
||||
});
|
||||
|
||||
// -- /app/:name - app metadata baked into the shell ----------
|
||||
// `/desktop/app/:name` is the same landing booted on the desktop
|
||||
// instead of the dashboard; the GUI strips the prefix and treats the
|
||||
// rest of the path identically, so both render the same shell.
|
||||
|
||||
const sendAppShell = async (
|
||||
req: express.Request,
|
||||
@@ -330,26 +362,11 @@ export class HomepageController extends PuterController {
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
router.get(
|
||||
'/desktop/app/:name',
|
||||
{ rateLimit: APP_LANDING_LIMIT },
|
||||
sendAppShell,
|
||||
);
|
||||
|
||||
// -- /show/* - launch explorer with the requested file path --
|
||||
|
||||
// Old /show links open the path in the dashboard's Files tab, query intact.
|
||||
router.get('/show/*splat', {}, (req, res) => {
|
||||
const filePath = req.path.slice('/show'.length);
|
||||
const launch: LaunchOptions = {
|
||||
on_initialized: [
|
||||
{
|
||||
$: 'window-call',
|
||||
fn_name: 'launch_app',
|
||||
args: [{ name: 'explorer', path: filePath }],
|
||||
},
|
||||
],
|
||||
};
|
||||
return sendShell(req, res, {}, launch);
|
||||
res.redirect(`/dashboard${queryOf(req)}#files${filePath}`);
|
||||
});
|
||||
|
||||
// -- /sw.js - the service worker, scoped to the whole origin --
|
||||
@@ -378,6 +395,38 @@ export class HomepageController extends PuterController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the GUI skip `/get-gui-token`, which reads the session cookie: the
|
||||
* digest of the token it would hand over, null when there is no session
|
||||
* cookie (either name the auth probe reads), and undefined (the GUI asks)
|
||||
* when a cookie didn't resolve to a session.
|
||||
*/
|
||||
#guiTokenDigest(
|
||||
req: express.Request,
|
||||
actor: ShellContext['actor'],
|
||||
): string | null | undefined {
|
||||
if (req.tokenSource === 'cookie') {
|
||||
if (!actor || !isPlainUserActor(actor) || !actor.session?.uid) {
|
||||
return undefined;
|
||||
}
|
||||
return this.services.auth.guiTokenDigest(
|
||||
actor.user as UserRow,
|
||||
actor.session.uid,
|
||||
);
|
||||
}
|
||||
const cookies = (req.cookies ?? {}) as Record<string, unknown>;
|
||||
const hasSessionCookie = [
|
||||
this.config.cookie_name,
|
||||
'puter_token_v2',
|
||||
].some(
|
||||
(name) =>
|
||||
!!name &&
|
||||
typeof cookies[name] === 'string' &&
|
||||
cookies[name] !== '',
|
||||
);
|
||||
return hasSessionCookie ? undefined : null;
|
||||
}
|
||||
}
|
||||
|
||||
const safeJsonParse = (s: string): Record<string, unknown> | null => {
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -49,7 +48,6 @@ export const puterControllers = {
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
hosting: HostingController,
|
||||
system: SystemController,
|
||||
fs: FSController,
|
||||
|
||||
@@ -372,26 +372,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) into the state redirect_uri', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({
|
||||
params: { provider: 'custom' },
|
||||
query: { return_to: '/desktop/app/my-App_2' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(String(decoded?.redirect_uri)).toBe(
|
||||
`${TEST_ORIGIN}/desktop/app/my-App_2`,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a non-whitelisted return_to', async () => {
|
||||
const bad_values = [
|
||||
'/app/evil/extra',
|
||||
@@ -400,6 +380,8 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
'//evil.test',
|
||||
'/settings',
|
||||
`/app/${'a'.repeat(101)}`,
|
||||
'/desktop',
|
||||
'/desktop/app/my-App_2',
|
||||
'/desktop/app/evil/extra',
|
||||
'/desktop/app/',
|
||||
'/dashboard/app/name',
|
||||
@@ -476,11 +458,11 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
sharedPath('b.txt'),
|
||||
sharedPath('a.txt'),
|
||||
],
|
||||
'/desktop',
|
||||
'/dashboard',
|
||||
),
|
||||
);
|
||||
const url = new URL(uri);
|
||||
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/desktop`);
|
||||
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/dashboard`);
|
||||
expect(url.searchParams.getAll('shared')).toEqual([
|
||||
sharedPath('a.txt'),
|
||||
sharedPath('b.txt'),
|
||||
|
||||
@@ -102,16 +102,14 @@ function signupOptionsFromState(
|
||||
}
|
||||
|
||||
// GUI pages an OIDC flow may return to: the root (where a share email lands),
|
||||
// /desktop, /dashboard, and direct app landings (/app/<name> and its
|
||||
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
|
||||
// AppDriver). Strict whitelist — never a client-supplied URL (no open
|
||||
// redirect).
|
||||
// /dashboard, and direct app landings (/app/<name>, mirroring
|
||||
// APP_NAME_REGEX in AppDriver). Strict whitelist — never a client-supplied URL
|
||||
// (no open redirect).
|
||||
function isWhitelistedReturnPath(path: string): boolean {
|
||||
return (
|
||||
path === '/' ||
|
||||
path === '/desktop' ||
|
||||
path === '/dashboard' ||
|
||||
/^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
|
||||
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,9 +436,8 @@ export class OIDCController extends PuterController {
|
||||
|
||||
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
|
||||
|
||||
// Optional GUI return path so login started from /desktop,
|
||||
// /dashboard, an /app/<name> landing, or a share link lands
|
||||
// back there.
|
||||
// Optional GUI return path so login started from /dashboard,
|
||||
// an /app/<name> landing, or a share link lands back there.
|
||||
const rawReturnTo = Array.isArray(req.query.return_to)
|
||||
? req.query.return_to[0]
|
||||
: req.query.return_to;
|
||||
|
||||
@@ -314,8 +314,8 @@ describe('OIDC account second-factor login', () => {
|
||||
|
||||
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
|
||||
await account(false);
|
||||
const response = await callback('login', { return_to: '/desktop' });
|
||||
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
|
||||
const response = await callback('login', { return_to: '/dashboard' });
|
||||
expect(response.headers.get('location')).toBe(`${env.origin}/dashboard`);
|
||||
expect(
|
||||
response.headers.get('set-cookie')?.includes('puter_auth_token='),
|
||||
).toBe(true);
|
||||
|
||||
@@ -24,11 +24,7 @@ import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { PEER_COSTS } from './costs.js';
|
||||
import {
|
||||
readClaimedGrantIdentifier,
|
||||
signGuestGrant,
|
||||
verifyGuestGrant,
|
||||
} from './guestGrant.js';
|
||||
import { signGuestGrant, verifyGuestGrant } from './guestGrant.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
@@ -191,19 +187,30 @@ export class PeerController extends PuterController {
|
||||
// guests share one bucket and no host can be relayed for by
|
||||
// more guests per minute than this — the only ceiling on guest
|
||||
// spend we can apply before the bytes are already spent.
|
||||
// A grant that doesn't parse can't name a bucket, so those
|
||||
// requests fall back to the caller's own network.
|
||||
// The limiter counts a request when it admits it, so only a
|
||||
// grant that would redeem names a host; otherwise anyone
|
||||
// holding the host's identifier could drain the bucket and
|
||||
// lock out every real guest. Anything else - forged, expired,
|
||||
// malformed, or no secret configured - is charged to the
|
||||
// caller's own network.
|
||||
rateLimit: {
|
||||
scope: 'peer-guest-turn',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: (req: Request) => {
|
||||
const claimed = readClaimedGrantIdentifier(
|
||||
(req.body as { grant?: unknown } | undefined)
|
||||
?.grant,
|
||||
);
|
||||
return claimed
|
||||
? `host:${claimed}`
|
||||
const secret =
|
||||
this.config.peers?.guest_turn?.grant_secret;
|
||||
const verified = secret
|
||||
? verifyGuestGrant({
|
||||
grant: (
|
||||
req.body as
|
||||
{ grant?: unknown } | undefined
|
||||
)?.grant,
|
||||
secret,
|
||||
})
|
||||
: null;
|
||||
return verified?.status === 'ok'
|
||||
? `host:${verified.customIdentifier}`
|
||||
: `net:${computeNetworkFingerprint(req)}`;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { toMicroCents } from '../../services/metering/utils';
|
||||
|
||||
// Microcents per byte of TURN egress ($0.05/GB).
|
||||
// Microcents per byte of TURN egress ($0.10/GB).
|
||||
export const PEER_COSTS = {
|
||||
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
|
||||
} as const;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readClaimedGrantIdentifier,
|
||||
signGuestGrant,
|
||||
verifyGuestGrant,
|
||||
} from './guestGrant.js';
|
||||
@@ -243,59 +242,3 @@ describe('signGuestGrant / verifyGuestGrant', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaimedGrantIdentifier', () => {
|
||||
it('reads the identifier from a valid grant', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it('reads the claimed identifier without checking the signature', () => {
|
||||
// Bucketing runs before verification, so this is expected: a forged
|
||||
// grant still names a bucket, and the handler still rejects it.
|
||||
const grant = forgeGrant(
|
||||
{ id: USER_ID, exp: 9_999_999_999, n: 'x' },
|
||||
{ secret: 'wrong-secret' },
|
||||
);
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it('reads an expired grant, which the handler then rejects', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
now: 1_000_000_000_000,
|
||||
});
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'expired',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-string', 42],
|
||||
['undefined', undefined],
|
||||
['a wrong-shaped string', 'not-a-grant'],
|
||||
['an unknown version', 'pg2.abc.def'],
|
||||
['an over-long string', `pg1.${'a'.repeat(600)}.sig`],
|
||||
[
|
||||
'a non-JSON payload',
|
||||
`pg1.${Buffer.from('x').toString('base64url')}.sig`,
|
||||
],
|
||||
])('returns null for %s', (_label, grant) => {
|
||||
expect(readClaimedGrantIdentifier(grant)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an identifier of the wrong shape', () => {
|
||||
const grant = forgeGrant({ id: 'nope', exp: 9_999_999_999 });
|
||||
expect(readClaimedGrantIdentifier(grant)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -171,37 +171,3 @@ export const verifyGuestGrant = ({
|
||||
expiresAt: payload.exp,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the claimed identifier out of a grant _without_ verifying it, for
|
||||
* rate-limit bucketing only.
|
||||
*
|
||||
* Bucketing has to happen before the handler runs, and a forged grant is
|
||||
* rejected there without reaching the upstream API — so an unverified read is
|
||||
* enough to put a host's guests in one bucket, and the worst a forger achieves
|
||||
* is choosing which bucket their own rejections land in. Never use this to
|
||||
* decide attribution.
|
||||
*
|
||||
* @returns The claimed identifier, or null if the grant doesn't parse.
|
||||
*/
|
||||
export const readClaimedGrantIdentifier = (grant: unknown): string | null => {
|
||||
if (typeof grant !== 'string' || grant.length > MAX_GRANT_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const parts = grant.split('.');
|
||||
if (parts.length !== 3 || parts[0] !== GRANT_VERSION) return null;
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(parts[1]!, 'base64url').toString('utf8'),
|
||||
) as { id?: unknown };
|
||||
if (
|
||||
typeof payload?.id !== 'string' ||
|
||||
!IDENTIFIER_RE.test(payload.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return payload.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -26,6 +26,7 @@
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { onResponseClose } from '../../core/http/onResponseClose.js';
|
||||
import type { UsageDetails } from '../../drivers/ai-chat/types.js';
|
||||
import { promoteStopForToolCalls } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
import { anthropicUsage } from './anthropicWire.js';
|
||||
@@ -79,7 +80,7 @@ export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
res.once('close', () => {
|
||||
onResponseClose(res, () => {
|
||||
ended = true;
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
@@ -301,6 +301,7 @@ 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,6 +547,7 @@ 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;
|
||||
@@ -555,6 +556,7 @@ 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');
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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,6 +1365,7 @@ describe('WebDAVController verbs', () => {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
sink.on('error', () => {});
|
||||
sink.on('finish', () => {
|
||||
captured.ended = true;
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -551,12 +550,7 @@ export class WebDAVController extends PuterController {
|
||||
'Content-Length': String(result.contentLength ?? 0),
|
||||
});
|
||||
}
|
||||
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.
|
||||
}
|
||||
await pipeToResponse(result.body, res);
|
||||
}
|
||||
|
||||
// -- PROPFIND ----------------------------------------------------
|
||||
@@ -813,7 +807,10 @@ export class WebDAVController extends PuterController {
|
||||
const userId = actor.user!.id as number;
|
||||
await this.#assertCreate(actor, davPath);
|
||||
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
|
||||
// A name only the collation matches is taken too.
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(davPath, {
|
||||
foldedNames: true,
|
||||
});
|
||||
if (existing)
|
||||
throw new HttpError(405, 'Already exists', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -937,7 +934,9 @@ export class WebDAVController extends PuterController {
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
|
||||
foldedNames: true,
|
||||
});
|
||||
if (destExists && !overwrite)
|
||||
throw new HttpError(412, 'Destination exists and Overwrite=F', {
|
||||
legacyCode: 'conflict',
|
||||
@@ -985,7 +984,9 @@ export class WebDAVController extends PuterController {
|
||||
await this.#assertCreate(actor, destPath);
|
||||
|
||||
const overwrite = req.headers.overwrite !== 'F';
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
|
||||
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
|
||||
foldedNames: true,
|
||||
});
|
||||
if (destExists && !overwrite)
|
||||
throw new HttpError(412, 'Destination exists and Overwrite=F', {
|
||||
legacyCode: 'conflict',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
TEST_USER_CREDENTIALS,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/** A PUT body is the file, whatever content type the client labels it with. */
|
||||
describe('PUT stores the body as sent', () => {
|
||||
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}`, ...init.headers },
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
['application/json', '{"name":"report","rows":[1,2,3]}'],
|
||||
// Not JSON at all: the label alone must not get the body rejected.
|
||||
['application/json', 'not json'],
|
||||
['text/plain;actually=json', '{"a":1}'],
|
||||
['application/x-www-form-urlencoded', 'a=1&b=two'],
|
||||
['application/octet-stream', 'plain bytes'],
|
||||
])(
|
||||
'with Content-Type %s',
|
||||
async (contentType, content) => {
|
||||
const path = `/${env.users.user.username}/Documents/put-${randomUUID().slice(0, 8)}.bin`;
|
||||
const put = await dav(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: content,
|
||||
});
|
||||
expect(put.status).toBe(201);
|
||||
|
||||
const get = await dav(path, { method: 'GET' });
|
||||
expect(get.status).toBe(200);
|
||||
expect(await get.text()).toBe(content);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it('with a form body over the form parser limit', async () => {
|
||||
const path = `/${env.users.user.username}/Documents/put-${randomUUID().slice(0, 8)}.bin`;
|
||||
const content = `a=${'x'.repeat(200 * 1024)}`;
|
||||
const put = await dav(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: content,
|
||||
});
|
||||
expect(put.status).toBe(201);
|
||||
|
||||
const get = await dav(path, { method: 'GET' });
|
||||
expect(await get.text()).toBe(content);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -243,6 +243,33 @@ describe('WispController', () => {
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('binds a token minted by an access token to that token while the replica lags', async () => {
|
||||
const { sessionActor, apiTokenActor, apiToken } = await freshUser();
|
||||
const db = server.clients.db;
|
||||
const original = db.read.bind(db);
|
||||
const spy = vi
|
||||
.spyOn(db, 'read')
|
||||
.mockImplementation(async (sql: string, params?: unknown[]) =>
|
||||
sql.includes("FROM `sessions` WHERE `kind` = 'access_token'")
|
||||
? []
|
||||
: original(sql, params),
|
||||
);
|
||||
let token: string;
|
||||
try {
|
||||
token = await relayTokenFor(apiTokenActor);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(await verifyStatus(token)).toBe(200);
|
||||
|
||||
await server.services.auth.revokeAccessToken(
|
||||
sessionActor,
|
||||
apiToken,
|
||||
);
|
||||
|
||||
expect(await verifyStatus(token)).toBe(403);
|
||||
});
|
||||
|
||||
it('applies the account checks to a token with no session claim', async () => {
|
||||
const { row } = await freshUser();
|
||||
const token = server.services.token.sign(
|
||||
|
||||
@@ -151,11 +151,12 @@ export class WispController extends PuterController {
|
||||
|
||||
/**
|
||||
* The session a relay token lives no longer than. An access token's is its
|
||||
* own session row; the actor doesn't carry it.
|
||||
* own session row.
|
||||
*/
|
||||
async #sessionUidFor(actor: Actor): Promise<string | undefined> {
|
||||
if (actor.session?.uid) return actor.session.uid;
|
||||
if (!actor.accessToken) return undefined;
|
||||
if (actor.accessToken.sessionUid) return actor.accessToken.sessionUid;
|
||||
const row = await this.stores.session.findActiveByAccessTokenUid(
|
||||
actor.accessToken.uid,
|
||||
);
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface ActorAccessToken {
|
||||
* reach while revokes, audits and gate hooks can still name the app.
|
||||
*/
|
||||
godmodeApp?: ActorApp | null;
|
||||
/**
|
||||
* The token's own session row, checked live when it authenticated. Unset
|
||||
* when the token carries no `session_uid` naming its row.
|
||||
*/
|
||||
sessionUid?: string;
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
@@ -71,8 +76,14 @@ 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 } | null;
|
||||
session?: {
|
||||
uid: string;
|
||||
kind?: string | null;
|
||||
parentUid?: 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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
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
|
||||
@@ -38,6 +39,6 @@ export const abortOnDisconnect = (res: Response): AbortSignal => {
|
||||
}
|
||||
};
|
||||
if (res.destroyed) onClose();
|
||||
else res.once('close', onClose);
|
||||
else onResponseClose(res, onClose);
|
||||
return abort.signal;
|
||||
};
|
||||
@@ -50,6 +50,13 @@ declare global {
|
||||
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* What `authenticate` threw while checking the token (a database
|
||||
* failure, say). The token may be fine; gates rethrow this instead
|
||||
* of answering 401.
|
||||
*/
|
||||
tokenAuthError?: unknown;
|
||||
|
||||
/**
|
||||
* Set when a token authenticated but its app is on the origin
|
||||
* blocklist. The auth probe leaves `actor` unset; gates translate
|
||||
@@ -97,6 +104,13 @@ 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.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
|
||||
|
||||
const loadShed = () =>
|
||||
Object.assign(new Error('Database operation failed'), {
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
|
||||
/**
|
||||
* A token whose lookup fails is not a dead token: clients discard a token on
|
||||
* 401 `token_auth_failed`, so that answer is kept for tokens that checked out
|
||||
* invalid.
|
||||
*/
|
||||
describe('auth probe when the token lookup fails', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const get = (path: string, token: string) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/** Run `fn` while the next user lookup rejects with `error`. */
|
||||
const withFailedUserLookup = async <T>(
|
||||
error: Error,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const spy = vi
|
||||
.spyOn(env.server.stores.user, 'getByUuid')
|
||||
.mockRejectedValueOnce(error);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it.each([
|
||||
['a session token', () => env.users.user.token],
|
||||
['an access token', () => env.users.user.apiToken],
|
||||
])(
|
||||
'answers %s with 503 db_unavailable during a database outage',
|
||||
async (_kind, tokenOf) => {
|
||||
const token = tokenOf();
|
||||
const res = await withFailedUserLookup(loadShed(), () =>
|
||||
get('/whoami', token),
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.headers.get('retry-after')).toBe('5');
|
||||
expect(await res.json()).toEqual({
|
||||
error: 'Service temporarily unavailable',
|
||||
message: 'Service temporarily unavailable',
|
||||
code: 'db_unavailable',
|
||||
});
|
||||
|
||||
expect((await get('/whoami', token)).status).toBe(200);
|
||||
},
|
||||
);
|
||||
|
||||
it('answers an unexpected lookup failure with a generic 500, not a 401', async () => {
|
||||
const res = await withFailedUserLookup(new Error('boom'), () =>
|
||||
get('/whoami', env.users.user.token),
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
expect((await res.json()).code).toBe('internal_error');
|
||||
});
|
||||
|
||||
it('serves a route that does not require auth anonymously', async () => {
|
||||
const res = await withFailedUserLookup(loadShed(), () =>
|
||||
get('/version', env.users.user.token),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('still answers an invalid token with 401 token_auth_failed', async () => {
|
||||
const res = await get('/whoami', 'not-a-real-jwt');
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({
|
||||
error: 'Authentication failed',
|
||||
message: 'Authentication failed',
|
||||
code: 'token_auth_failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -555,17 +555,18 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
expect(req.tokenAuthFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
|
||||
it('never rejects — records the error, not tokenAuthFailed, when AuthService throws', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNext('throw');
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer bad-tok' } }),
|
||||
makeReq({ headers: { authorization: 'Bearer some-tok' } }),
|
||||
);
|
||||
// Critical invariant: the probe NEVER rejects, no matter what.
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.tokenAuthFailed).toBe(true);
|
||||
expect(req.tokenAuthFailed).toBeUndefined();
|
||||
expect(req.tokenAuthError).toBeInstanceOf(Error);
|
||||
expect(req.actor).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -880,4 +881,27 @@ describe('createAuthProbe (integration) — real session token → real actor',
|
||||
expect(req.actor).toBeUndefined();
|
||||
expect(req.tokenAuthFailed).toBe(true);
|
||||
});
|
||||
|
||||
it('records a failed user lookup on a valid token without marking it invalid', async () => {
|
||||
const user = await makeUser();
|
||||
const { token } = await authService.createSessionToken(user);
|
||||
const lookupError = Object.assign(new Error('db down'), {
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(server.stores.user, 'getByUuid')
|
||||
.mockRejectedValueOnce(lookupError);
|
||||
try {
|
||||
const probe = createAuthProbe({ authService });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: `Bearer ${token}` } }),
|
||||
);
|
||||
expect(req.actor).toBeUndefined();
|
||||
expect(req.tokenAuthFailed).toBeUndefined();
|
||||
expect(req.tokenAuthError).toBe(lookupError);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,9 +45,9 @@ interface AuthProbeOptions {
|
||||
* and valid — attaches an `Actor` to `req.actor`.
|
||||
*
|
||||
* Key property: this middleware **never rejects**. Missing tokens, malformed
|
||||
* tokens, expired tokens, tokens pointing at deleted users — all result in
|
||||
* `req.actor` being left undefined. Per-route gates decide whether absence is
|
||||
* acceptable.
|
||||
* tokens, expired tokens, tokens pointing at deleted users, and lookups that
|
||||
* fail outright all result in `req.actor` being left undefined. Per-route gates
|
||||
* decide whether absence is acceptable.
|
||||
*
|
||||
* Token lookup order:
|
||||
*
|
||||
@@ -153,8 +153,10 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
} else if (result.invalid) {
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
} catch {
|
||||
req.tokenAuthFailed = true;
|
||||
} catch (err) {
|
||||
// Not a verdict on the token: routes that require auth answer
|
||||
// with this error, routes that don't serve the request anonymously.
|
||||
req.tokenAuthError = err;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -224,7 +226,7 @@ const extractToken = (
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Query string (used by e.g. QR login, asset URLs).
|
||||
// 5. Query string (used by e.g. asset URLs).
|
||||
const queryToken = (req.query as { auth_token?: unknown } | undefined)
|
||||
?.auth_token;
|
||||
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
@@ -195,7 +196,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.
|
||||
res.once('close', () => {
|
||||
onResponseClose(res, () => {
|
||||
try {
|
||||
if (!isMeteredHost(req)) return;
|
||||
const actor = resolveEgressActor(req);
|
||||
|
||||
@@ -23,8 +23,11 @@ import { createFingerprintMiddleware } from './fingerprint';
|
||||
|
||||
const VALID_FP = 'dev-fingerprint-abc123';
|
||||
|
||||
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
|
||||
const middleware = createFingerprintMiddleware();
|
||||
const run = (
|
||||
reqPartial: Partial<Request> & Record<string, unknown>,
|
||||
opts?: Parameters<typeof createFingerprintMiddleware>[0],
|
||||
) => {
|
||||
const middleware = createFingerprintMiddleware(opts);
|
||||
const req = {
|
||||
headers: {},
|
||||
...reqPartial,
|
||||
@@ -90,4 +93,21 @@ 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,6 +33,20 @@ 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>;
|
||||
@@ -65,15 +79,22 @@ 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 = (): RequestHandler => {
|
||||
export const createFingerprintMiddleware = (
|
||||
opts: { tlsFingerprintHeader?: string } = {},
|
||||
): RequestHandler => {
|
||||
const tlsHeader = opts.tlsFingerprintHeader?.toLowerCase();
|
||||
return (req, _res, next) => {
|
||||
req.networkFingerprint = computeNetworkFingerprint(req);
|
||||
req.deviceFingerprint = readDeviceFingerprint(req);
|
||||
req.tlsFingerprint = readTlsFingerprint(req, tlsHeader);
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -288,6 +288,22 @@ describe('requireAuthGate', () => {
|
||||
});
|
||||
expectHttpError(got, 401, 'reauth_required');
|
||||
});
|
||||
|
||||
it('passes on the error when the token could not be checked', () => {
|
||||
const lookupError = Object.assign(new Error('down'), {
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
const got = runGate(requireAuthGate(), { tokenAuthError: lookupError });
|
||||
expect(got).toBe(lookupError);
|
||||
});
|
||||
|
||||
it('still answers token_auth_failed when a check found the token invalid', () => {
|
||||
const got = runGate(requireAuthGate(), {
|
||||
tokenAuthFailed: true,
|
||||
tokenAuthError: new Error('down'),
|
||||
});
|
||||
expectHttpError(got, 401, 'token_auth_failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireUserActorGate ────────────────────────────────────────────
|
||||
@@ -330,6 +346,14 @@ describe('requireUserActorGate', () => {
|
||||
expectHttpError(got, 401, 'token_missing');
|
||||
});
|
||||
|
||||
it('passes on the error when the token could not be checked', () => {
|
||||
const lookupError = new Error('down');
|
||||
const got = runGate(requireUserActorGate(), {
|
||||
tokenAuthError: lookupError,
|
||||
});
|
||||
expect(got).toBe(lookupError);
|
||||
});
|
||||
|
||||
it('rejects a FULL-ACCESS access token too — account routes stay closed', () => {
|
||||
// The account wall is actor-type based: even a full-access PAT (which
|
||||
// the resource wall lets through) is rejected here, so it can never
|
||||
|
||||
@@ -50,7 +50,7 @@ import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
import '../expressAugmentation';
|
||||
|
||||
const rejectAuth = (req: Request): HttpError => {
|
||||
const rejectAuth = (req: Request): unknown => {
|
||||
if (req.requiresReauth) {
|
||||
return new HttpError(401, 'Re-authentication required', {
|
||||
legacyCode: 'reauth_required',
|
||||
@@ -71,6 +71,9 @@ const rejectAuth = (req: Request): HttpError => {
|
||||
legacyCode: 'token_auth_failed',
|
||||
});
|
||||
}
|
||||
// The token couldn't be checked, so a 401 would wrongly tell the client
|
||||
// to discard it; the error handler answers (503 for a database outage).
|
||||
if (req.tokenAuthError !== undefined) return req.tokenAuthError;
|
||||
return new HttpError(401, 'Missing authentication token', {
|
||||
legacyCode: 'token_missing',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib';
|
||||
import express, {
|
||||
type ErrorRequestHandler,
|
||||
type RequestHandler,
|
||||
} from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { SLICE_BYTES } from '../../../util/slicedJson';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
|
||||
const LIMIT = 4 * 1024 * 1024;
|
||||
const TEXT_AS_JSON = 'text/plain;actually=json';
|
||||
|
||||
type Verify = NonNullable<Parameters<typeof express.json>[0]>['verify'];
|
||||
const captureRawBody: Verify = (req, _res, buf) => {
|
||||
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||
};
|
||||
|
||||
/** An app that answers with what its parsers left on the request. */
|
||||
const listen = (parsers: RequestHandler[]): Promise<Server> => {
|
||||
const app = express();
|
||||
app.use(...parsers);
|
||||
app.post('/', (req, res) => {
|
||||
const rawBody = (req as { rawBody?: Buffer }).rawBody;
|
||||
res.json({
|
||||
body: req.body,
|
||||
hasBody: req.body !== undefined,
|
||||
rawBody: rawBody?.toString('base64'),
|
||||
});
|
||||
});
|
||||
const onError: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
res.status(err.statusCode ?? 500).json({ type: err.type });
|
||||
};
|
||||
app.use(onError);
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
};
|
||||
|
||||
describe('createJsonBodyParser', () => {
|
||||
let sliced: Server;
|
||||
let framework: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
sliced = await listen([
|
||||
createJsonBodyParser({ limit: LIMIT, verify: captureRawBody }),
|
||||
]);
|
||||
// What the server ran before: one parser per content type.
|
||||
framework = await listen([
|
||||
express.json({ limit: LIMIT, verify: captureRawBody }),
|
||||
express.json({
|
||||
limit: LIMIT,
|
||||
type: (req) => req.headers['content-type'] === TEXT_AS_JSON,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sliced?.close();
|
||||
framework?.close();
|
||||
});
|
||||
|
||||
const post = async (
|
||||
server: Server,
|
||||
body: string | Buffer | undefined,
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
return { status: response.status, answer: await response.json() };
|
||||
};
|
||||
|
||||
const large = `${JSON.stringify({
|
||||
text: 'é😀 "quoted" \\ line\n'.repeat(SLICE_BYTES / 8),
|
||||
list: Array.from({ length: 20_000 }, (_, i) => ({
|
||||
i,
|
||||
on: i % 2 === 0,
|
||||
})),
|
||||
}).slice(0, -1)},"__proto__":{"nested":true}}`;
|
||||
|
||||
it.each<[string, string | Buffer | undefined, Record<string, string>]>([
|
||||
['an object', '{"a":1}', { 'Content-Type': 'application/json' }],
|
||||
['an array', '[1,2]', { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'a body sent as text',
|
||||
'{"interface":"x","auth_token":"t"}',
|
||||
{ 'Content-Type': TEXT_AS_JSON },
|
||||
],
|
||||
['a large body', large, { 'Content-Type': 'application/json' }],
|
||||
['a large body sent as text', large, { 'Content-Type': TEXT_AS_JSON }],
|
||||
[
|
||||
'a charset parameter',
|
||||
large,
|
||||
{ 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
],
|
||||
[
|
||||
'a quoted charset parameter',
|
||||
'{"a":"é"}',
|
||||
{ 'Content-Type': 'application/json;charset="utf-8"' },
|
||||
],
|
||||
[
|
||||
'another parameter',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/json; profile=x' },
|
||||
],
|
||||
['an empty body', '', { 'Content-Type': 'application/json' }],
|
||||
['no body', undefined, { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'a byte order mark',
|
||||
`\ufeff${large}`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'only a byte order mark',
|
||||
'\ufeff',
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
['only whitespace', ' \n', { 'Content-Type': 'application/json' }],
|
||||
['a bare string', '"text"', { 'Content-Type': 'application/json' }],
|
||||
['a bare number', ' 12', { 'Content-Type': 'application/json' }],
|
||||
['null', 'null', { 'Content-Type': 'application/json' }],
|
||||
['malformed JSON', '{"a":', { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'malformed large JSON',
|
||||
`${large.slice(0, -1)},]`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a large bare string',
|
||||
JSON.stringify('x'.repeat(4 * SLICE_BYTES)),
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'invalid UTF-8',
|
||||
Buffer.concat([
|
||||
Buffer.from('{"a":"'),
|
||||
Buffer.from([0xff, 0xe2, 0x82]),
|
||||
Buffer.from('"}'),
|
||||
]),
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a body over the limit',
|
||||
`["${'x'.repeat(LIMIT)}"]`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a gzipped body',
|
||||
gzipSync(large),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' },
|
||||
],
|
||||
[
|
||||
'a deflated body',
|
||||
deflateSync(large),
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Encoding': 'deflate',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a brotli body',
|
||||
brotliCompressSync(large),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'br' },
|
||||
],
|
||||
[
|
||||
'a body that inflates past the limit',
|
||||
gzipSync(`["${'x'.repeat(LIMIT)}"]`),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' },
|
||||
],
|
||||
[
|
||||
'an unknown content encoding',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'lzw' },
|
||||
],
|
||||
['plain text', '{"a":1}', { 'Content-Type': 'text/plain' }],
|
||||
[
|
||||
'text with the marker and a charset',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': `${TEXT_AS_JSON}; charset=utf-8` },
|
||||
],
|
||||
[
|
||||
'a JSON-suffixed type',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/vnd.api+json' },
|
||||
],
|
||||
[
|
||||
'form data',
|
||||
'a=1',
|
||||
{ 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
],
|
||||
['no content type', '{"a":1}', { 'Content-Type': '' }],
|
||||
])('answers %s the way express.json does', async (_name, body, headers) => {
|
||||
const got = await post(sliced, body, headers);
|
||||
const want = await post(framework, body, headers);
|
||||
expect(got).toEqual(want);
|
||||
});
|
||||
|
||||
it('leaves a body in another charset unread', async () => {
|
||||
for (const charset of ['utf-16', 'utf8', 'latin1']) {
|
||||
const { status, answer } = await post(
|
||||
sliced,
|
||||
Buffer.from('{"a":1}', 'utf16le'),
|
||||
{ 'Content-Type': `application/json; charset=${charset}` },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(answer.hasBody).toBe(false);
|
||||
}
|
||||
// The last charset named is the one that counts.
|
||||
const { answer } = await post(sliced, '{"a":1}', {
|
||||
'Content-Type': 'application/json; charset=utf-8; charset=utf-16',
|
||||
});
|
||||
expect(answer.hasBody).toBe(false);
|
||||
});
|
||||
|
||||
it('parses bodies that arrive together, each to its own result', async () => {
|
||||
const bodies = Array.from({ length: 6 }, (_, n) =>
|
||||
JSON.stringify({ n, pad: 'x'.repeat((n + 1) * SLICE_BYTES) }),
|
||||
);
|
||||
const answers = await Promise.all(
|
||||
bodies.map((body) =>
|
||||
post(sliced, body, { 'Content-Type': 'application/json' }),
|
||||
),
|
||||
);
|
||||
answers.forEach(({ status, answer }, n) => {
|
||||
expect(status).toBe(200);
|
||||
expect(answer.body).toEqual(JSON.parse(bodies[n]!));
|
||||
});
|
||||
// A failed parse hands its turn on like a finished one.
|
||||
const mixed = await Promise.all([
|
||||
post(sliced, `${bodies[5]!},`, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
post(sliced, bodies[4], { 'Content-Type': 'application/json' }),
|
||||
post(sliced, bodies[3], { 'Content-Type': 'application/json' }),
|
||||
]);
|
||||
expect(mixed.map(({ status }) => status)).toEqual([400, 200, 200]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 express, { type Request, type RequestHandler } from 'express';
|
||||
import { parseJsonInSlices, SLICE_BYTES } from '../../../util/slicedJson';
|
||||
|
||||
/** What puter.js labels a JSON driver call, to keep it a simple CORS request. */
|
||||
const TEXT_AS_JSON = 'text/plain;actually=json';
|
||||
|
||||
const UTF8_CHARSET = /;\s*charset=("?)utf-8\1\s*(?:;|$)/i;
|
||||
|
||||
/** No charset, or exactly UTF-8. Anything else is left to `express.json`. */
|
||||
const declaresUtf8 = (contentType: string): boolean => {
|
||||
const mentions = contentType.match(/charset/gi)?.length ?? 0;
|
||||
return mentions === 0 || (mentions === 1 && UTF8_CHARSET.test(contentType));
|
||||
};
|
||||
|
||||
const isUtf8Json = (req: Request): boolean => {
|
||||
const contentType = req.headers['content-type'];
|
||||
if (contentType === TEXT_AS_JSON) return true;
|
||||
return (
|
||||
typeof contentType === 'string' &&
|
||||
Boolean(req.is('application/json')) &&
|
||||
declaresUtf8(contentType)
|
||||
);
|
||||
};
|
||||
|
||||
/** The error `express.json` raises, which the error handler answers with a 400. */
|
||||
const parseFailed = (message: string): Error =>
|
||||
Object.assign(new SyntaxError(message), {
|
||||
status: 400,
|
||||
statusCode: 400,
|
||||
expose: true,
|
||||
type: 'entity.parse.failed',
|
||||
});
|
||||
|
||||
const waiting: Array<() => void> = [];
|
||||
let parsing = false;
|
||||
|
||||
/**
|
||||
* Bodies too large for one `JSON.parse` call parse one at a time, so a single
|
||||
* tree is under construction however many arrive together.
|
||||
*/
|
||||
const parseInTurn = async (body: Buffer): Promise<unknown> => {
|
||||
if (body.length <= SLICE_BYTES) return parseJsonInSlices(body);
|
||||
if (parsing) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
parsing = true;
|
||||
try {
|
||||
return await parseJsonInSlices(body);
|
||||
} finally {
|
||||
// Handing over keeps `parsing` set, so a new arrival can't cut in.
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
else parsing = false;
|
||||
}
|
||||
};
|
||||
|
||||
interface JsonBodyOptions {
|
||||
limit: number;
|
||||
verify?: NonNullable<Parameters<typeof express.raw>[0]>['verify'];
|
||||
}
|
||||
|
||||
/**
|
||||
* `express.json` for UTF-8 bodies, with the parse done in slices so a large
|
||||
* body can't hold the event loop. Reading, limits, decompression and `verify`
|
||||
* are the framework's; the result and the errors match `express.json`.
|
||||
*/
|
||||
export const createJsonBodyParser = ({
|
||||
limit,
|
||||
verify,
|
||||
}: JsonBodyOptions): RequestHandler => {
|
||||
const read = express.raw({
|
||||
limit,
|
||||
verify,
|
||||
type: (req) => isUtf8Json(req as Request),
|
||||
});
|
||||
|
||||
return (req, res, next) => {
|
||||
read(req, res, async (err?: unknown) => {
|
||||
// Not a Buffer: the reader skipped this request.
|
||||
if (err || !Buffer.isBuffer(req.body)) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
let body: Buffer = req.body;
|
||||
req.body = undefined;
|
||||
// The framework's decoder drops a byte order mark.
|
||||
if (body[0] === 0xef && body[1] === 0xbb && body[2] === 0xbf) {
|
||||
body = body.subarray(3);
|
||||
}
|
||||
if (body.length === 0) {
|
||||
req.body = {};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseInTurn(body);
|
||||
} catch (e) {
|
||||
next(parseFailed((e as Error).message));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
next(parseFailed('JSON body must be an object or an array'));
|
||||
return;
|
||||
}
|
||||
req.body = parsed;
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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 { request, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
import express, {
|
||||
type ErrorRequestHandler,
|
||||
type RequestHandler,
|
||||
} from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
} from './largeJsonBody';
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const TOKEN = 'valid-token';
|
||||
const PAD = 'x'.repeat(DEFAULT_JSON_LIMIT_BYTES);
|
||||
|
||||
/** Stands in for the auth probe: one token is valid, wherever it is sent. */
|
||||
const authProbe: RequestHandler = (req, _res, next) => {
|
||||
const token =
|
||||
(req.body as { auth_token?: string } | undefined)?.auth_token ??
|
||||
req.headers.authorization;
|
||||
if (!req.actor && token === TOKEN) {
|
||||
req.actor = { user: { uuid: 'u1' } } as typeof req.actor;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
describe('a JSON body over the default limit', () => {
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.post(
|
||||
'/call',
|
||||
createLargeJsonBodyParser({
|
||||
authProbe,
|
||||
limitBytes: 128 * MiB,
|
||||
budgetBytes: 256 * MiB,
|
||||
perUserBytes: 128 * MiB,
|
||||
}),
|
||||
);
|
||||
app.use(createJsonBodyParser({ limit: DEFAULT_JSON_LIMIT_BYTES }));
|
||||
app.use(authProbe);
|
||||
app.post('/call', (req, res) => {
|
||||
const body = req.body as { auth_token?: string; pad: string };
|
||||
res.json({
|
||||
user: req.actor?.user.uuid ?? null,
|
||||
token: body.auth_token ?? null,
|
||||
padLength: body.pad.length,
|
||||
});
|
||||
});
|
||||
const onError: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
res.status(err.statusCode ?? 500).json({});
|
||||
};
|
||||
app.use(onError);
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
/** Sends `chunks` one write at a time, so the server sees them apart. */
|
||||
const post = (chunks: string[], headers: Record<string, string> = {}) =>
|
||||
new Promise<{ status: number; answer: Record<string, unknown> }>(
|
||||
(resolve, reject) => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/call',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;actually=json',
|
||||
'Content-Length': chunks.reduce(
|
||||
(total, chunk) =>
|
||||
total + Buffer.byteLength(chunk),
|
||||
0,
|
||||
),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const parts: Buffer[] = [];
|
||||
res.on('data', (part: Buffer) => parts.push(part));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode!,
|
||||
answer: JSON.parse(
|
||||
Buffer.concat(parts).toString(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
void (async () => {
|
||||
for (const chunk of chunks) {
|
||||
req.write(chunk);
|
||||
await nextTurn();
|
||||
}
|
||||
req.end();
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
it('is accepted when it opens with a valid auth_token', async () => {
|
||||
const { status, answer } = await post([
|
||||
`{"auth_token":"${TOKEN}","pad":"${PAD}"}`,
|
||||
]);
|
||||
expect(status).toBe(200);
|
||||
expect(answer).toEqual({
|
||||
user: 'u1',
|
||||
token: TOKEN,
|
||||
padLength: PAD.length,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it('is accepted when the token arrives in pieces, with spacing', async () => {
|
||||
const opening = ` {\n "auth_token" : "${TOKEN}" ,`;
|
||||
const { status, answer } = await post([...opening, `"pad":"${PAD}"}`]);
|
||||
expect(status).toBe(200);
|
||||
expect(answer.user).toBe('u1');
|
||||
}, 60_000);
|
||||
|
||||
it('is accepted with a header credential, as before', async () => {
|
||||
const { status, answer } = await post([`{"pad":"${PAD}"}`], {
|
||||
Authorization: TOKEN,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(answer).toEqual({
|
||||
user: 'u1',
|
||||
token: null,
|
||||
padLength: PAD.length,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'its token comes after other fields',
|
||||
`{"pad":"${PAD}","auth_token":"${TOKEN}"}`,
|
||||
],
|
||||
['its token is not valid', `{"auth_token":"expired","pad":"${PAD}"}`],
|
||||
['it has no token', `{"pad":"${PAD}"}`],
|
||||
[
|
||||
'a later field replaces the token it opens with',
|
||||
`{"auth_token":"${TOKEN}","pad":"${PAD}","auth_token":"other"}`,
|
||||
],
|
||||
[
|
||||
'the token it opens with is nested',
|
||||
`{"args":{"auth_token":"${TOKEN}"},"pad":"${PAD}"}`,
|
||||
],
|
||||
])(
|
||||
'is refused with 413 when %s',
|
||||
async (_name, body) => {
|
||||
expect((await post([body])).status).toBe(413);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '../HttpError';
|
||||
@@ -35,7 +36,7 @@ const makeReq = (
|
||||
user: string | null = 'u1',
|
||||
body?: unknown,
|
||||
) =>
|
||||
({
|
||||
Object.assign(Readable.from([]), {
|
||||
complete: true,
|
||||
body,
|
||||
headers: {
|
||||
|
||||
@@ -17,13 +17,55 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import express, { type Request, type RequestHandler } from 'express';
|
||||
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. */
|
||||
export const DEFAULT_JSON_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/** How much of a body is read to find a token it opens with. */
|
||||
const LEADING_TOKEN_BYTES = 8 * 1024;
|
||||
const LEADING_TOKEN =
|
||||
/^[ \t\n\r]*\{[ \t\n\r]*"auth_token"[ \t\n\r]*:[ \t\n\r]*"([^"\\]+)"/;
|
||||
|
||||
/**
|
||||
* The first `bytes` of the body, put back for the parser to read. Empty when
|
||||
* the body ends or fails before that many arrive.
|
||||
*/
|
||||
const peekBody = (req: Request, bytes: number): Promise<Buffer> =>
|
||||
new Promise((resolve) => {
|
||||
const settle = (head: Buffer | null) => {
|
||||
req.off('readable', onReadable);
|
||||
req.off('end', onGone);
|
||||
req.off('close', onGone);
|
||||
req.off('error', onGone);
|
||||
if (head) req.unshift(head);
|
||||
resolve(head ?? Buffer.alloc(0));
|
||||
};
|
||||
const onReadable = () => {
|
||||
// Null until `bytes` are buffered.
|
||||
const head = req.read(bytes) as Buffer | null;
|
||||
if (head) settle(head);
|
||||
};
|
||||
const onGone = () => settle(null);
|
||||
req.on('readable', onReadable);
|
||||
req.on('end', onGone);
|
||||
req.on('close', onGone);
|
||||
req.on('error', onGone);
|
||||
});
|
||||
|
||||
/**
|
||||
* The `auth_token` a JSON body opens with, which puter.js sends first so it can
|
||||
* be read ahead of the body.
|
||||
*/
|
||||
const leadingBodyToken = async (req: Request): Promise<string | undefined> => {
|
||||
const head = await peekBody(req, LEADING_TOKEN_BYTES);
|
||||
return LEADING_TOKEN.exec(head.toString('latin1'))?.[1];
|
||||
};
|
||||
|
||||
interface LargeJsonBodyOptions {
|
||||
/** The global auth probe; it skips requests that already carry an actor. */
|
||||
authProbe: RequestHandler;
|
||||
@@ -38,9 +80,9 @@ interface LargeJsonBodyOptions {
|
||||
|
||||
/**
|
||||
* Parses JSON bodies over the default limit, up to `limitBytes`, once the
|
||||
* caller's header or cookie credential resolves. Unauthenticated requests and
|
||||
* requests without a Content-Length fall through to the global parser and its
|
||||
* limit.
|
||||
* caller's credential resolves: a header, a cookie, or an `auth_token` the body
|
||||
* opens with. Unauthenticated requests and requests without a Content-Length
|
||||
* fall through to the global parser and its limit.
|
||||
*
|
||||
* The parsed body lives as long as the request, so a body's bytes count against
|
||||
* both budgets until its response closes. Over the user's budget gets a 429,
|
||||
@@ -53,82 +95,91 @@ export const createLargeJsonBodyParser = ({
|
||||
perUserBytes,
|
||||
accepts,
|
||||
}: LargeJsonBodyOptions): RequestHandler => {
|
||||
const json = express.json({ limit: limitBytes });
|
||||
const textAsJson = express.json({
|
||||
limit: limitBytes,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
});
|
||||
const json = createJsonBodyParser({ limit: limitBytes });
|
||||
let inFlightBytes = 0;
|
||||
const inFlightByUser = new Map<string, number>();
|
||||
|
||||
return (req, res, next) => {
|
||||
const probe = (req: Request, res: Response) =>
|
||||
new Promise<void>((resolve) => {
|
||||
void authProbe(req, res, () => resolve());
|
||||
});
|
||||
|
||||
return async (req, res, next) => {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (!(declared > DEFAULT_JSON_LIMIT_BYTES && declared <= limitBytes)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
void authProbe(req, res, () => {
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
await probe(req, res);
|
||||
let bodyToken: string | undefined;
|
||||
if (!req.actor && !res.closed) {
|
||||
bodyToken = await leadingBodyToken(req);
|
||||
if (bodyToken) {
|
||||
// Where the probe looks for a body token.
|
||||
req.body = { auth_token: bodyToken };
|
||||
await probe(req, res);
|
||||
req.body = undefined;
|
||||
}
|
||||
}
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
onResponseClose(res, () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
// The caller was resolved from the token the body opens with,
|
||||
// so the parsed body has to name the same one.
|
||||
const tokenMoved =
|
||||
bodyToken !== undefined &&
|
||||
(req.body as { auth_token?: unknown } | undefined)
|
||||
?.auth_token !== bodyToken;
|
||||
if (tokenMoved || (accepts && !accepts(req))) {
|
||||
// The same answer the default limit gives.
|
||||
next(
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
textAsJson(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
if (accepts && !accepts(req)) {
|
||||
// The same answer the default limit gives.
|
||||
next(
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
});
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
const normalizeOrigin = (raw: string | undefined): string =>
|
||||
export const normalizeOrigin = (raw: string | undefined): string =>
|
||||
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../actor';
|
||||
import {
|
||||
@@ -1132,14 +1132,150 @@ describe('resolvePublicHostedIdentity', () => {
|
||||
const reqOf = (init: {
|
||||
cookies?: Record<string, string>;
|
||||
actor?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
}): Request =>
|
||||
({
|
||||
cookies: init.cookies ?? {},
|
||||
actor: init.actor,
|
||||
headers: {},
|
||||
query: {},
|
||||
headers: init.headers ?? {},
|
||||
query: init.query ?? {},
|
||||
}) as unknown as Request;
|
||||
|
||||
describe('with the launch token in the Referer', () => {
|
||||
const host = 'launch.site.puter.localhost';
|
||||
const cookieName = HOSTED_ASSET_COOKIES.public.name;
|
||||
|
||||
const launch = async () => {
|
||||
const user = await makeUser();
|
||||
const sub = `launch-${uuidv4().slice(0, 8)}`;
|
||||
const app = await server.stores.app.create(
|
||||
{ name: sub, title: sub, index_url: `http://${host}/` },
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const token = await authService.getUserAppToken(
|
||||
{ user } as unknown as Parameters<
|
||||
typeof authService.getUserAppToken
|
||||
>[0],
|
||||
app.uid,
|
||||
);
|
||||
const actor = (await authService.authenticateFromToken(token))!;
|
||||
const cookie = await authService.createHostedAssetToken('public', {
|
||||
appUid: app.uid,
|
||||
userUid: user.uuid,
|
||||
sessionUuid: actor.session?.uid,
|
||||
subdomain: 'launch',
|
||||
host,
|
||||
});
|
||||
return { user, appUid: app.uid, token, cookie, actor };
|
||||
};
|
||||
|
||||
const resolve = (
|
||||
appUid: string,
|
||||
init: Parameters<typeof reqOf>[0],
|
||||
expectedHost = host,
|
||||
) =>
|
||||
resolvePublicHostedIdentity({
|
||||
req: reqOf(init),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: appUid,
|
||||
expectedSubdomain: 'launch',
|
||||
expectedHost,
|
||||
});
|
||||
|
||||
const refererFor = (token: string) =>
|
||||
`http://${host}/?puter.auth.token=${encodeURIComponent(token)}`;
|
||||
|
||||
it('takes the cookie when the token names its user and session', async () => {
|
||||
const { user, appUid, token, cookie } = await launch();
|
||||
const authenticate = vi.spyOn(authService, 'authenticate');
|
||||
try {
|
||||
const out = await resolve(appUid, {
|
||||
cookies: { [cookieName]: cookie },
|
||||
headers: { referer: refererFor(token) },
|
||||
});
|
||||
expect(out).toMatchObject({
|
||||
source: 'public-cookie',
|
||||
userUid: user.uuid,
|
||||
hasValidPublicCookie: true,
|
||||
});
|
||||
expect(authenticate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
authenticate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('authenticates a query token even when it names the cookie identity', async () => {
|
||||
const { user, appUid, token, cookie } = await launch();
|
||||
const out = await resolve(appUid, {
|
||||
cookies: { [cookieName]: cookie },
|
||||
query: { 'puter.auth.token': token },
|
||||
});
|
||||
expect(out.source).toBe('query');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(
|
||||
(out as { hasValidPublicCookie?: boolean })
|
||||
.hasValidPublicCookie,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('authenticates the token when the cookie was minted for another host', async () => {
|
||||
const { user, appUid, token, cookie } = await launch();
|
||||
const out = await resolve(
|
||||
appUid,
|
||||
{
|
||||
cookies: { [cookieName]: cookie },
|
||||
headers: { referer: refererFor(token) },
|
||||
},
|
||||
'other.site.puter.localhost',
|
||||
);
|
||||
expect(out.source).toBe('referrer');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
|
||||
it('authenticates a token that names another app under the same session', async () => {
|
||||
const { user, appUid, cookie, actor } = await launch();
|
||||
const otherAppToken = server.services.token.sign('auth', {
|
||||
type: 'app-under-user',
|
||||
version: '2',
|
||||
user_uid: user.uuid,
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
session_uid: actor.session!.uid,
|
||||
});
|
||||
const authenticate = vi.spyOn(authService, 'authenticate');
|
||||
try {
|
||||
const out = await resolve(appUid, {
|
||||
cookies: { [cookieName]: cookie },
|
||||
headers: { referer: refererFor(otherAppToken) },
|
||||
});
|
||||
expect(authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(out.source).toBe('public-cookie');
|
||||
} finally {
|
||||
authenticate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never resolves a scoped access token, with or without the cookie', async () => {
|
||||
const { user, appUid, cookie } = await launch();
|
||||
const scoped = await authService.createAccessToken(
|
||||
makeActor({
|
||||
user: { id: user.id, uuid: user.uuid, username: user.username },
|
||||
}),
|
||||
[[`user:${user.uuid}:email:read`]],
|
||||
);
|
||||
const withCookie = await resolve(appUid, {
|
||||
cookies: { [cookieName]: cookie },
|
||||
headers: { referer: refererFor(scoped) },
|
||||
});
|
||||
expect(withCookie.source).toBe('public-cookie');
|
||||
const withoutCookie = await resolve(appUid, {
|
||||
headers: { referer: refererFor(scoped) },
|
||||
});
|
||||
expect(withoutCookie.source).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the cookie identity when present and valid', async () => {
|
||||
const user = await makeUser();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
|
||||
@@ -86,6 +86,10 @@ export interface PrivateIdentity {
|
||||
hasValidPrivateCookie?: boolean;
|
||||
}
|
||||
|
||||
type PublicCookieClaims = Awaited<
|
||||
ReturnType<AuthService['verifyHostedAssetToken']>
|
||||
>;
|
||||
|
||||
interface SubdomainLike {
|
||||
user_id?: number | null;
|
||||
}
|
||||
@@ -533,8 +537,63 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
|
||||
HOSTED_ASSET_COOKIES.public;
|
||||
const publicCookieToken =
|
||||
(typeof cookies?.[publicCookieNameV2] === 'string'
|
||||
? cookies[publicCookieNameV2]
|
||||
: null) ??
|
||||
(typeof cookies?.[publicCookieNameLegacy] === 'string'
|
||||
? cookies[publicCookieNameLegacy]
|
||||
: null);
|
||||
let publicCookieClaims: Promise<PublicCookieClaims | null> | undefined;
|
||||
const verifyPublicCookie = () =>
|
||||
(publicCookieClaims ??= (async () => {
|
||||
if (!publicCookieToken) return null;
|
||||
try {
|
||||
return await authService.verifyHostedAssetToken(
|
||||
'public',
|
||||
publicCookieToken,
|
||||
{
|
||||
appUid: expectedAppUid,
|
||||
subdomain: expectedSubdomain,
|
||||
host: expectedHost,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// v1-signed cookies land here and get re-minted.
|
||||
return null;
|
||||
}
|
||||
})());
|
||||
const publicCookieIdentity = (claims: PublicCookieClaims) => ({
|
||||
source: 'public-cookie' as const,
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
});
|
||||
|
||||
const bootstrap = getBootstrapToken(req);
|
||||
if (bootstrap) {
|
||||
// A launched app's own assets carry its launch token in the Referer.
|
||||
// When that token names the cookie's user and session, the cookie
|
||||
// already stands for it and nothing needs re-authenticating or minting.
|
||||
if (
|
||||
bootstrap.source === 'referrer' &&
|
||||
publicCookieToken &&
|
||||
expectedAppUid
|
||||
) {
|
||||
const named = authService.signedSessionClaims(bootstrap.token);
|
||||
if (named && (!named.appUid || named.appUid === expectedAppUid)) {
|
||||
const claims = await verifyPublicCookie();
|
||||
if (
|
||||
claims &&
|
||||
claims.userUid === named.userUid &&
|
||||
claims.sessionUuid === named.sessionUuid
|
||||
) {
|
||||
return publicCookieIdentity(claims);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const actor = await authService.authenticateFromToken(
|
||||
bootstrap.token,
|
||||
@@ -554,36 +613,8 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
|
||||
HOSTED_ASSET_COOKIES.public;
|
||||
const publicCookieToken =
|
||||
(typeof cookies?.[publicCookieNameV2] === 'string'
|
||||
? cookies[publicCookieNameV2]
|
||||
: null) ??
|
||||
(typeof cookies?.[publicCookieNameLegacy] === 'string'
|
||||
? cookies[publicCookieNameLegacy]
|
||||
: null);
|
||||
if (publicCookieToken) {
|
||||
try {
|
||||
const claims = await authService.verifyHostedAssetToken(
|
||||
'public',
|
||||
publicCookieToken,
|
||||
{
|
||||
appUid: expectedAppUid,
|
||||
subdomain: expectedSubdomain,
|
||||
host: expectedHost,
|
||||
},
|
||||
);
|
||||
return {
|
||||
source: 'public-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
};
|
||||
} catch {
|
||||
/* fall through — v1-signed cookies land here and get re-minted */
|
||||
}
|
||||
}
|
||||
const claims = await verifyPublicCookie();
|
||||
if (claims) return publicCookieIdentity(claims);
|
||||
|
||||
return { source: 'none' };
|
||||
}
|
||||
|
||||
@@ -2763,6 +2763,321 @@ describe('public hosted asset billing identity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('public hosted assets requested with the launch token in the Referer', () => {
|
||||
const cookieName = HOSTED_ASSET_COOKIES.public.name;
|
||||
|
||||
const makeLaunchedApp = async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${owner.username}`,
|
||||
))!;
|
||||
const sub = `ref-${uuidv4().slice(0, 8)}`;
|
||||
const host = `${sub}.site.puter.localhost`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: home.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`/${owner.username}/index.html`,
|
||||
Buffer.from('<html></html>'),
|
||||
);
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`/${owner.username}/app.css`,
|
||||
Buffer.from('body{}'),
|
||||
);
|
||||
const app = await server.stores.app.create(
|
||||
{ name: sub, title: sub, index_url: `http://${host}/` },
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
return { owner, sub, host, appUid: app.uid };
|
||||
};
|
||||
|
||||
const cookieFor = async (
|
||||
token: string,
|
||||
site: { sub: string; host: string; appUid: string },
|
||||
) => {
|
||||
const actor = (await server.services.auth.authenticateFromToken(
|
||||
token,
|
||||
))!;
|
||||
return server.services.auth.createHostedAssetToken('public', {
|
||||
appUid: site.appUid,
|
||||
userUid: actor.user.uuid,
|
||||
sessionUuid: actor.session?.uid,
|
||||
subdomain: site.sub,
|
||||
host: site.host,
|
||||
});
|
||||
};
|
||||
|
||||
const refererFor = (host: string, token: string) =>
|
||||
`http://${host}/?puter.app_instance_id=x&puter.auth.token=${encodeURIComponent(token)}`;
|
||||
|
||||
const serve = async (
|
||||
host: string,
|
||||
init: {
|
||||
path?: string;
|
||||
cookies?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
} = {},
|
||||
) => {
|
||||
const req = makeReq({ hostname: host, path: '/app.css', ...init });
|
||||
const { res, out } = makeRes();
|
||||
await buildMiddleware()(req, res, vi.fn());
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(out.statusCode).toBe(200);
|
||||
return { req, out };
|
||||
};
|
||||
|
||||
const claimsOf = (cookie: string, appUid: string, host: string) =>
|
||||
server.services.auth.verifyHostedAssetToken('public', cookie, {
|
||||
appUid,
|
||||
host,
|
||||
});
|
||||
|
||||
const REDIS_METHODS = [
|
||||
'get',
|
||||
'mget',
|
||||
'exists',
|
||||
'hget',
|
||||
'hgetall',
|
||||
'smembers',
|
||||
'set',
|
||||
'del',
|
||||
'expire',
|
||||
'incr',
|
||||
'incrby',
|
||||
'hset',
|
||||
'sadd',
|
||||
'pipeline',
|
||||
'multi',
|
||||
];
|
||||
|
||||
/** Redis calls, JWT signs and full authentications made by `fn`. */
|
||||
const measure = async <T>(fn: () => Promise<T>) => {
|
||||
const redis = server.clients.redis as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const redisSpies = REDIS_METHODS.filter(
|
||||
(m) => typeof redis[m] === 'function',
|
||||
).map((m) => [m, vi.spyOn(redis as never, m as never)] as const);
|
||||
const sign = vi.spyOn(server.services.token, 'sign');
|
||||
const authenticate = vi.spyOn(server.services.auth, 'authenticate');
|
||||
try {
|
||||
const result = await fn();
|
||||
const redisCalls: Record<string, number> = {};
|
||||
for (const [m, spy] of redisSpies) {
|
||||
const n = (spy as unknown as { mock: { calls: unknown[] } })
|
||||
.mock.calls.length;
|
||||
if (n) redisCalls[m] = n;
|
||||
}
|
||||
return {
|
||||
result,
|
||||
redisCalls,
|
||||
signs: sign.mock.calls.length,
|
||||
authentications: authenticate.mock.calls.length,
|
||||
};
|
||||
} finally {
|
||||
for (const [, spy] of redisSpies) spy.mockRestore();
|
||||
sign.mockRestore();
|
||||
authenticate.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it('costs the same as the cookie alone when the token names the cookie user and session', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const visitor = await makeUser();
|
||||
const token = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
site.appUid,
|
||||
);
|
||||
const cookies = { [cookieName]: await cookieFor(token, site) };
|
||||
const headers = { referer: refererFor(site.host, token) };
|
||||
|
||||
// Warm the caches both paths read.
|
||||
await serve(site.host, { cookies });
|
||||
await serve(site.host, { cookies, headers });
|
||||
|
||||
const cookieOnly = await measure(() => serve(site.host, { cookies }));
|
||||
const withReferer = await measure(() =>
|
||||
serve(site.host, { cookies, headers }),
|
||||
);
|
||||
|
||||
expect(withReferer.result.req.egressActor?.user?.uuid).toBe(
|
||||
visitor.uuid,
|
||||
);
|
||||
expect(withReferer.result.out.cookies).toEqual({});
|
||||
expect(withReferer.signs).toBe(0);
|
||||
expect(withReferer.authentications).toBe(0);
|
||||
expect(withReferer.redisCalls).toEqual(cookieOnly.redisCalls);
|
||||
expect(cookieOnly.signs).toBe(0);
|
||||
});
|
||||
|
||||
it('bills the token account when the launch could not replace an older cookie', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const previous = await makeUser();
|
||||
const current = await makeUser();
|
||||
const previousToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(previous),
|
||||
site.appUid,
|
||||
);
|
||||
const currentToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(current),
|
||||
site.appUid,
|
||||
);
|
||||
const oldCookies = {
|
||||
[cookieName]: await cookieFor(previousToken, site),
|
||||
};
|
||||
|
||||
const mint = vi
|
||||
.spyOn(server.services.auth, 'createHostedAssetToken')
|
||||
.mockRejectedValueOnce(new Error('mint failed'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
let launch;
|
||||
try {
|
||||
launch = await serve(site.host, {
|
||||
path: '/index.html',
|
||||
cookies: oldCookies,
|
||||
query: { 'puter.auth.token': currentToken },
|
||||
});
|
||||
} finally {
|
||||
mint.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
expect(launch.out.cookies).toEqual({});
|
||||
expect(launch.req.egressActor?.user?.uuid).toBe(current.uuid);
|
||||
|
||||
const asset = await serve(site.host, {
|
||||
cookies: oldCookies,
|
||||
headers: { referer: refererFor(site.host, currentToken) },
|
||||
});
|
||||
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
|
||||
const replaced = asset.out.cookies[cookieName];
|
||||
expect(replaced).toBeTruthy();
|
||||
expect((await claimsOf(replaced, site.appUid, site.host)).userUid).toBe(
|
||||
current.uuid,
|
||||
);
|
||||
});
|
||||
|
||||
it('bills the token account when a cached page of another account loads assets', async () => {
|
||||
// The document never reaches the server (served from an app's own
|
||||
// cache), so only the assets show which account launched it.
|
||||
const site = await makeLaunchedApp();
|
||||
const previous = await makeUser();
|
||||
const current = await makeUser();
|
||||
const previousToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(previous),
|
||||
site.appUid,
|
||||
);
|
||||
const currentToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(current),
|
||||
site.appUid,
|
||||
);
|
||||
const oldCookies = {
|
||||
[cookieName]: await cookieFor(previousToken, site),
|
||||
};
|
||||
|
||||
for (const path of ['/app.css', '/index.html']) {
|
||||
const asset = await serve(site.host, {
|
||||
path,
|
||||
cookies: oldCookies,
|
||||
headers: { referer: refererFor(site.host, currentToken) },
|
||||
});
|
||||
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
|
||||
const replaced = asset.out.cookies[cookieName];
|
||||
expect(
|
||||
(await claimsOf(replaced, site.appUid, site.host)).userUid,
|
||||
).toBe(current.uuid);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-mints the cookie when the token names the same user under another session', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const visitor = await makeUser();
|
||||
const appToken = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
site.appUid,
|
||||
);
|
||||
const cookies = { [cookieName]: await cookieFor(appToken, site) };
|
||||
const { session, token: sessionToken } =
|
||||
await server.services.auth.createSessionToken(visitor as never);
|
||||
|
||||
const asset = await serve(site.host, {
|
||||
cookies,
|
||||
headers: { referer: refererFor(site.host, sessionToken) },
|
||||
});
|
||||
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
|
||||
const replaced = asset.out.cookies[cookieName];
|
||||
expect(replaced).toBeTruthy();
|
||||
expect(
|
||||
(await claimsOf(replaced, site.appUid, site.host)).sessionUuid,
|
||||
).toBe(session.uuid);
|
||||
});
|
||||
|
||||
it('falls back to the cookie when the Referer token does not verify', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const visitor = await makeUser();
|
||||
const token = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
site.appUid,
|
||||
);
|
||||
const cookies = { [cookieName]: await cookieFor(token, site) };
|
||||
const tampered = `${token.slice(0, -4)}AAAA`;
|
||||
|
||||
const asset = await serve(site.host, {
|
||||
cookies,
|
||||
headers: { referer: refererFor(site.host, tampered) },
|
||||
});
|
||||
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
|
||||
expect(asset.out.cookies).toEqual({});
|
||||
});
|
||||
|
||||
it('bills the site owner once the shared session is revoked', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const visitor = await makeUser();
|
||||
const token = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
site.appUid,
|
||||
);
|
||||
const cookies = { [cookieName]: await cookieFor(token, site) };
|
||||
await server.services.auth.revokeAllSessionsForUserId(visitor.id);
|
||||
|
||||
const asset = await serve(site.host, {
|
||||
cookies,
|
||||
headers: { referer: refererFor(site.host, token) },
|
||||
});
|
||||
expect(asset.req.egressActor?.user?.uuid).toBe(site.owner.uuid);
|
||||
expect(asset.out.cookies).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps responses that set the cookie out of shared caches', async () => {
|
||||
const site = await makeLaunchedApp();
|
||||
const visitor = await makeUser();
|
||||
const token = await server.services.auth.getUserAppToken(
|
||||
actorFor(visitor),
|
||||
site.appUid,
|
||||
);
|
||||
|
||||
const launch = await serve(site.host, {
|
||||
path: '/index.html',
|
||||
query: { 'puter.auth.token': token },
|
||||
});
|
||||
const cookie = launch.out.cookies[cookieName];
|
||||
expect(cookie).toBeTruthy();
|
||||
expect(launch.out.headers['Cache-Control']).toBe('private');
|
||||
|
||||
const asset = await serve(site.host, {
|
||||
cookies: { [cookieName]: cookie },
|
||||
headers: { referer: refererFor(site.host, token) },
|
||||
});
|
||||
expect(asset.out.cookies).toEqual({});
|
||||
expect(asset.out.headers['Cache-Control']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('site rooted in the owner tree, root moved to Trash', () => {
|
||||
it('stops serving a trashed folder and resumes when restored', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
|
||||
@@ -548,6 +548,8 @@ export const createPuterSiteMiddleware = (
|
||||
requestHostname: host,
|
||||
}),
|
||||
);
|
||||
// A shared cache must not hand this visitor's cookie to others.
|
||||
res.set('Cache-Control', 'private');
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort — don't block the public file serve.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -1169,7 +1170,7 @@ export function concurrencyGate(opts) {
|
||||
const { release } = guardSlot(slot, '[concurrent]');
|
||||
const onDone = () => void release();
|
||||
res.once('finish', onDone);
|
||||
res.once('close', onDone);
|
||||
onResponseClose(res, onDone);
|
||||
if (res.destroyed || res.writableFinished) onDone();
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user