mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
223
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8d556cd5 | ||
|
|
af01a554fe | ||
|
|
3e11e7dc79 | ||
|
|
5b1b5b8a7a | ||
|
|
b2eb8ed895 | ||
|
|
d1cbfb9fb1 | ||
|
|
ac44de819d | ||
|
|
4ad53899da | ||
|
|
c253b7e05e | ||
|
|
647995133e | ||
|
|
9d3a049988 | ||
|
|
f044d8d071 | ||
|
|
aea6e662f6 | ||
|
|
31f6dc2d37 | ||
|
|
2b03bc53b1 | ||
|
|
3ea0010220 | ||
|
|
395e4bd54a | ||
|
|
b55cb715ca | ||
|
|
2e4d918900 | ||
|
|
8c799a433a | ||
|
|
5f84d5897b | ||
|
|
c0aff857a4 | ||
|
|
ff606abf3c | ||
|
|
8159998830 | ||
|
|
395f7f6adb | ||
|
|
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 | ||
|
|
09c6ce6f46 | ||
|
|
0fdf40b8da | ||
|
|
5b850113ff | ||
|
|
51bf8697a8 | ||
|
|
8c14e7de30 | ||
|
|
84faf5c40c | ||
|
|
aba2666f26 | ||
|
|
abe6882de9 | ||
|
|
770af49528 | ||
|
|
47b4cc1158 | ||
|
|
e253a7f862 | ||
|
|
af35783df4 | ||
|
|
cb214f28cc | ||
|
|
8ed439e897 | ||
|
|
672a704193 | ||
|
|
35ae464d86 | ||
|
|
79f24ed3df | ||
|
|
f30d2134b1 | ||
|
|
c24c8589cb | ||
|
|
2f8f81e621 | ||
|
|
7d02c7f622 | ||
|
|
d2bbe2b5e2 | ||
|
|
3ff70ef97f | ||
|
|
f791024b2e | ||
|
|
cce9718b45 | ||
|
|
9758ada36e | ||
|
|
37dcee78f0 | ||
|
|
162398a827 | ||
|
|
6785b4d9cc | ||
|
|
d2acdafaed | ||
|
|
6a46363d6d | ||
|
|
2de9d509e8 | ||
|
|
763742dbc0 | ||
|
|
7f0cd1689b | ||
|
|
9d22f39928 | ||
|
|
ef185c04ca | ||
|
|
dec7d1fd96 | ||
|
|
fb9e7d70c6 | ||
|
|
819e77bb4c | ||
|
|
7355eaa77b | ||
|
|
d55aac1f98 | ||
|
|
c6987d9606 | ||
|
|
796b42c74d | ||
|
|
e2043a5e58 | ||
|
|
8b98c7ada8 | ||
|
|
b27e84d181 | ||
|
|
add0e7be7d | ||
|
|
b5d221ede6 | ||
|
|
3137a631a7 | ||
|
|
57706d8093 | ||
|
|
fec1d1916f | ||
|
|
da369039e0 | ||
|
|
4f36152850 | ||
|
|
86db96d587 | ||
|
|
214ffb6605 | ||
|
|
871cc2cf87 | ||
|
|
ba002be0f7 | ||
|
|
2832926f8d | ||
|
|
1ab1c04760 | ||
|
|
63a98f89ff | ||
|
|
d2cef27085 | ||
|
|
74b60c1227 | ||
|
|
2ed3e78dfa | ||
|
|
22ebd353b8 | ||
|
|
7551e12370 | ||
|
|
6e29e32fe9 | ||
|
|
54bfc0ffe5 | ||
|
|
c27883134b | ||
|
|
cf4c1f6b64 | ||
|
|
305d20ab1c | ||
|
|
0ce6b0646a | ||
|
|
6b5c4f3d9e | ||
|
|
d6fdc5f268 | ||
|
|
4f3f946941 | ||
|
|
4e36672b6b | ||
|
|
6b0b530ab1 | ||
|
|
68b8f341e9 | ||
|
|
c3756d3ac3 | ||
|
|
0c927ac5a4 | ||
|
|
64c3b3c0ce | ||
|
|
e9a4c7a461 | ||
|
|
be9cd9eb89 | ||
|
|
8b64cc5076 | ||
|
|
d3869b7846 | ||
|
|
91230697cd | ||
|
|
56e4856518 | ||
|
|
8acd73675c | ||
|
|
fc4af97885 | ||
|
|
7cec710014 | ||
|
|
48cd4ea355 | ||
|
|
a110be0340 | ||
|
|
93798cba93 | ||
|
|
848e336455 | ||
|
|
a9a8df6409 | ||
|
|
0a8b37ae56 | ||
|
|
23782dec12 | ||
|
|
6a6cf78c03 | ||
|
|
8264837d9f | ||
|
|
eb92421a59 | ||
|
|
e0af3361ed | ||
|
|
9bd7ba7079 | ||
|
|
8f3d4cc40f | ||
|
|
96d91cd74e | ||
|
|
cdb3110857 | ||
|
|
d934a47c5e | ||
|
|
0128e8779d | ||
|
|
89f070f7d0 | ||
|
|
58456ae87c | ||
|
|
abb70c14a6 | ||
|
|
18fd655645 | ||
|
|
480fa7b85b | ||
|
|
47b54aacb4 | ||
|
|
dd3c0dd485 | ||
|
|
e0689986ed | ||
|
|
0f54588b31 | ||
|
|
0cb9f7707d | ||
|
|
622b782f4e |
No files matched your search
+8
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
# The last matching rule takes precedence.
|
||||
|
||||
- @Salazareo
|
||||
* @Salazareo
|
||||
|
||||
# GUI
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
# Backend AI
|
||||
|
||||
/src/backend/controllers/puterai/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai-\*/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiCostFactor* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiPrices* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiProviderKey* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
|
||||
@@ -29,7 +32,7 @@
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox\* @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
|
||||
@@ -42,7 +45,7 @@
|
||||
/src/backend/services/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/acl/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService\* @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
|
||||
/src/backend/stores/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/group/ @jfcastro92 @Salazareo
|
||||
@@ -59,7 +62,7 @@
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer\* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# SDK: sharing and permissions
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -53,7 +55,7 @@ jobs:
|
||||
# step above fails; the tests skip themselves without the key.
|
||||
- name: Run live realtime conversation tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/convoLive.test.ts
|
||||
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/realtimeLive.test.ts
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Notify HeyPuter (dev)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
puter_ref:
|
||||
description: "puter branch, tag or SHA"
|
||||
type: string
|
||||
default: "main"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
if: github.repository == 'HeyPuter/puter'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require maintain or admin
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
|
||||
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
|
||||
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
notify:
|
||||
needs: authorize
|
||||
runs-on: ubuntu-latest
|
||||
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer dev deploy
|
||||
env:
|
||||
PUTER_REF: ${{ inputs.puter_ref }}
|
||||
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
jq -nc --arg ref "$PUTER_REF" \
|
||||
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d @-
|
||||
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -5,9 +5,12 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
environment: heyputer-dispatch
|
||||
steps:
|
||||
- name: Trigger heyputer build
|
||||
run: |
|
||||
@@ -15,4 +18,4 @@ jobs:
|
||||
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
|
||||
@@ -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,10 +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
|
||||
uses: actions/download-artifact@v8
|
||||
id: pr-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
@@ -186,15 +198,16 @@ jobs:
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
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.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2.13.2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
@@ -202,8 +215,8 @@ jobs:
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
|
||||
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:
|
||||
|
||||
@@ -9,6 +9,7 @@ Use these as the source of truth before exploring further:
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), actors, and extensions.
|
||||
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
|
||||
- [doc/ai-drivers.md](doc/ai-drivers.md) — where AI driver code lives (interfaces, providers, protocols, shared), the import rules between those folders, and how to add a provider or an AI interface.
|
||||
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
|
||||
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
@@ -70,7 +71,7 @@ Commit messages and PR descriptions never credit anyone or mention a bug report.
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** In files you're already touching, fix the typo, the dead import, the missing test, the bit you had to read twice. Don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't have written, debugged, or defended in review is not.
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/ai/shared/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +98,10 @@ Layer rules:
|
||||
- **Drivers and controllers stay thin.** Domain rules a second entry point needs (signup, app CRUD, worker deploy, AI routing/billing) live in a service, not in the first driver or controller that needed them.
|
||||
- **Prefer explicit arguments over [`Context`](src/backend/core/context.ts) (ALS).** Use it only for request-scoped values that would otherwise thread through many layers; today that's mostly `actor` and `req`.
|
||||
|
||||
### AI drivers
|
||||
|
||||
AI drivers live in [src/backend/drivers/ai/](src/backend/drivers/ai/): `interfaces/` holds the Puter-facing drivers, `providers/<vendor>/<interface>/` holds vendor code, `protocols/` holds wire protocols several vendors speak, and `shared/` holds helpers more than one interface uses. Follow [doc/ai-drivers.md](doc/ai-drivers.md) when adding or moving AI code.
|
||||
|
||||
### Extensions
|
||||
|
||||
[extensions/](extensions/) is for **non-crucial parts of the system**: Puter must still work with the extension removed. If core needs to call it, it belongs in core ([whoami](extensions/whoami.ts) is the cautionary example). Inside an extension, follow the same layered structure, unless it only needs a few route handlers, in which case the `extension.get/post/...` helpers are enough on their own. The `extension` API is in [doc/architecture.md](doc/architecture.md#extension-api).
|
||||
@@ -159,13 +164,42 @@ The reference has two kinds of page:
|
||||
|
||||
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
|
||||
|
||||
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
|
||||
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation.
|
||||
|
||||
**Sidebar order.** Order the API areas in the sidebar by how often developers use them, most common first. Overview stays first and Objects stays last. Inside an area, order the pages by use too, and keep pages that make up one feature next to each other, such as `share()`, `unshare()`, `listShared()`, and `listSharedByMe()`. A group goes where its most-used page would go. A helper that only supports another function, such as `listModels()` for `chat()`, goes by its own use. Put create, list, get, update, and delete in that order, and constants such as `MAX_KEY_SIZE` last. The function list on the area's overview page uses the sidebar's order. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
|
||||
|
||||
### Function page layout
|
||||
|
||||
A function page opens with one sentence that says what the function does, in its own paragraph, such as "Given a prompt, generate an image using AI." Put any more detail in the paragraphs after it.
|
||||
|
||||
After the intro, a function page has these `##` sections, in this order:
|
||||
|
||||
1. Syntax
|
||||
2. Parameters
|
||||
3. Return value
|
||||
4. Errors, if the function has any
|
||||
5. Sections for specific topics, if the page needs them, with `## Model-specific features` last
|
||||
6. Examples
|
||||
|
||||
[chat.md](src/docs/src/AI/chat.md) follows this layout.
|
||||
|
||||
- **Options as a table.** Document an options object as a table with Option, Type, and Description columns. Give each option its own row, even when two options go together, such as `width` and `height`. Order the rows by how often developers use them, most common first, without group rows. In chat.md that's the core options, then reasoning, tools, response handling, request control, and output.
|
||||
- **Common options in the table, model-specific behavior in its own section.** A page whose function works with many models or providers, such as [chat.md](src/docs/src/AI/chat.md) and [txt2img.md](src/docs/src/AI/txt2img.md), splits its options this way:
|
||||
- **The options table** under Parameters lists the options that work with every model, and describes each one the way it works for every model.
|
||||
- **`## Model-specific features`**, the last section before Examples, covers everything else. It has a `###` for each model family or provider, named after it, such as "OpenAI models" or "Cloudflare models". Under it go the options only some models accept, and the common options a model handles differently, such as a narrower range, other allowed values, or an option it ignores.
|
||||
- **Write there only what's different.** A `###` lists an option only when that model or provider adds it or handles it differently. An option that works the same everywhere stays out, even if the provider supports it, and a model or provider with nothing different gets no `###`.
|
||||
- **Model-specific features is the only place for model-specific details.** Every limit, format, allowed value, default, price, or quirk that applies to some models goes there, in that model's or provider's `###`, and nowhere else. The options table and topic sections (such as Image inputs or Video input) describe what's true for every model and link to Model-specific features for the rest. Don't add a table or sentence there that compares providers or repeats a model's limits. Each copy is another source of truth to keep in sync, and copies drift when a model changes.
|
||||
- **Link the two.** Directly under the options table, put `Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).` A row whose option behaves differently on some models can also end with `See [Model-specific features](#model-specific-features).`
|
||||
- **A long topic gets its own `##` section.** When explaining an option or feature takes more than a sentence or two, such as function calling or output dimensions, give it a section between Errors and Examples and link to it from the options table. This keeps the reference sections short.
|
||||
- **Usage patterns are examples.** A way of using the API, such as stopping a stream, goes in a runnable example that the relevant section links to ("See the [stop a stream example](/playground/ai-chat-stop-stream/)."), not in a section of its own.
|
||||
- **Each example has a `###` heading** under `## Examples`, so every example shows in the table of contents.
|
||||
|
||||
### Writing docs
|
||||
|
||||
These apply to reference pages and recipes alike.
|
||||
|
||||
- **Document the developer-facing side only.** The reader is building on top of puter.js, so cover what affects their code: signatures, defaults, return shapes, errors, limits, and behavior they can observe. Leave out internal workings (which service handles the call, storage layout, caching, retries) unless they change what the developer has to do. [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) is the exception: it publishes every limit, including timeouts and caps that come from how the backend works.
|
||||
- **Document Puter as it runs on puter.com.** The docs are for developers building on production Puter. Leave out behavior that only happens on a self-hosted or differently configured server, such as a provider that isn't set up there.
|
||||
- **Describe the current state.** Docs aren't a changelog. Write how the API behaves now, without "now", "no longer", "existing code is unaffected", or other wording that compares it to an older version.
|
||||
- **Verify before you document.** Run the calls you describe against a running Puter instead of inferring behavior from the source.
|
||||
- **Write plainly.** Use the literal words a developer would say out loud, with contractions and everyday words. No idioms or figurative verbs ("walk away from something shared with you" → "remove your own access", "nothing to page through" → "without pagination"). No formal register ("present only while more pages remain" → "includes a `cursor` if there are more pages to load"). Prefer dev terms such as atomic, concurrent, and insertion order.
|
||||
@@ -173,6 +207,7 @@ These apply to reference pages and recipes alike.
|
||||
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
|
||||
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
|
||||
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
|
||||
- **No `##` or `###` headings inside tabs.** The table of contents lists every `##` and `###` heading on the page, including the ones in a hidden tab (an `example-content` block switched by the `example-group` pills), so its links lead to content the reader can't see. Put the heading above the tabs, or use `####` inside a tab, which the table of contents skips.
|
||||
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
|
||||
|
||||
### Recipes
|
||||
|
||||
+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) ─────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# AI Drivers
|
||||
|
||||
Where the AI driver code lives under [src/backend/drivers/ai/](../src/backend/drivers/ai/), what each folder owns, and how to add a provider or a new AI interface. The general layer rules are in [architecture.md](architecture.md) and [AGENTS.md](../AGENTS.md#backend). This page covers what is specific to AI.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
src/backend/drivers/ai/
|
||||
├── interfaces/ Puter-facing drivers, one folder per AI interface
|
||||
├── providers/ Vendor code, as providers/<vendor>/<interface>/
|
||||
├── protocols/ Wire protocols that several vendors speak
|
||||
└── shared/ Helpers that more than one interface uses
|
||||
```
|
||||
|
||||
| Folder | Owns | Example |
|
||||
| --- | --- | --- |
|
||||
| `interfaces/<interface>/` | The driver class, the interface's contracts in `types.ts`, its provider base class where one exists, and the helpers and tests that apply to every provider of that interface. | [interfaces/chat/ChatCompletionDriver.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.ts) |
|
||||
| `providers/<vendor>/<interface>/` | One vendor's implementation of one interface: the adapter, its model catalog and prices, its fixtures, and its tests. | [providers/gemini/video/](../src/backend/drivers/ai/providers/gemini/video/) |
|
||||
| `providers/<vendor>/` | Helpers that several of that vendor's interfaces use. | [providers/gemini/geminiRest.ts](../src/backend/drivers/ai/providers/gemini/geminiRest.ts) |
|
||||
| `protocols/openaiCompatible/` | The OpenAI wire protocol: the base provider class, message and usage conversion, and parameter mapping for Chat Completions and Responses. | [protocols/openaiCompatible/OpenAICompatProvider.ts](../src/backend/drivers/ai/protocols/openaiCompatible/OpenAICompatProvider.ts) |
|
||||
| `shared/` | Helpers that more than one interface uses: limits, the provider registry, the remote catalog cache, job polling, streamed charging, and media input. | [shared/aiLimits.ts](../src/backend/drivers/ai/shared/aiLimits.ts) |
|
||||
|
||||
Helpers that non-AI code also uses stay in [src/backend/drivers/util/](../src/backend/drivers/util/): file input, data URIs, upstream error mapping, and stream piping.
|
||||
|
||||
AI code that a service also needs lives in [src/backend/util/](../src/backend/util/), because a service can't import from the driver layer. The realtime relay in `services/realtime/` bills through the same cost factor as the drivers, so these three files are there:
|
||||
|
||||
| File | Holds |
|
||||
| --- | --- |
|
||||
| [aiCostFactor.ts](../src/backend/util/aiCostFactor.ts) | `withAiCostFactor`, the metering view every AI driver and the realtime relay record usage through, and `withAiCreditHold`. |
|
||||
| [aiProviderKey.ts](../src/backend/util/aiProviderKey.ts) | `readProviderKey`, which reads an API key from `config.providers` blocks. |
|
||||
| [aiPrices.ts](../src/backend/util/aiPrices.ts) | Vendor prices that a driver and a service both bill. |
|
||||
|
||||
### Interfaces
|
||||
|
||||
| Folder | Driver | `driverInterface` | `driverName` | Key in `puterDrivers` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `interfaces/chat/` | `ChatCompletionDriver` | `puter-chat-completion` | `ai-chat` | `aiChat` |
|
||||
| `interfaces/image/` | `ImageGenerationDriver` | `puter-image-generation` | `ai-image` | `aiImage` |
|
||||
| `interfaces/video/` | `VideoGenerationDriver` | `puter-video-generation` | `ai-video` | `aiVideo` |
|
||||
| `interfaces/textToSpeech/` | `TTSDriver` | `puter-tts` | `ai-tts` | `aiTts` |
|
||||
| `interfaces/speechToText/` | `SpeechToTextDriver` | `puter-speech2txt` | `ai-speech2txt` | `aiSpeech2Txt` |
|
||||
| `interfaces/speechToSpeech/` | `VoiceChangerDriver` | `puter-speech2speech` | `ai-speech2speech` | `aiSpeech2Speech` |
|
||||
| `interfaces/ocr/` | `OCRDriver` | `puter-ocr` | `ai-ocr` | `aiOcr` |
|
||||
|
||||
Folder names are not runtime identifiers. `driverInterface`, `driverName`, driver aliases, the keys under `providers` in config, provider ids, model ids, and usage keys are public contracts. Moving or renaming a file never changes them.
|
||||
|
||||
## Request flow
|
||||
|
||||
A call goes from the interface to a provider, and from there to a shared protocol when the vendor speaks one:
|
||||
|
||||
```text
|
||||
interfaces/chat/ChatCompletionDriver.ts
|
||||
validates the call, picks the model and provider, holds credits
|
||||
→ providers/deepseek/chat/DeepSeekProvider.ts
|
||||
DeepSeek's catalog, endpoint, and request differences
|
||||
→ protocols/openaiCompatible/OpenAICompatProvider.ts
|
||||
the OpenAI-compatible request, response, and stream handling
|
||||
```
|
||||
|
||||
A provider whose vendor has its own API, such as `ClaudeProvider` or `ElevenLabsTTSProvider`, implements the interface's contract directly and uses no protocol.
|
||||
|
||||
## Import rules
|
||||
|
||||
- `shared/` imports nothing from `interfaces/`, `providers/`, or `protocols/`.
|
||||
- Nothing in `services/`, `stores/`, or `clients/` imports from `drivers/ai/`. Code that a service and a driver both need goes in `src/backend/util/`.
|
||||
- `providers/` and `protocols/` import an interface's `types.ts` and helpers. They never import a driver class. Their tests can, to drive a provider through the real driver.
|
||||
- Only an interface's driver file imports providers, to register them. The interface's helpers don't.
|
||||
- An interface doesn't import another interface. When a second interface needs a helper, move the helper to `shared/`, or to `providers/<vendor>/` when it belongs to one vendor.
|
||||
- A vendor folder imports another vendor's folder only when it serves that vendor's models. `providers/azure/` reads the OpenAI and xAI catalogs for that reason.
|
||||
|
||||
## Where a file goes
|
||||
|
||||
| The code is | It goes in |
|
||||
| --- | --- |
|
||||
| Input validation, gates, routing, credit holds, or anything else every provider of the interface gets | `interfaces/<interface>/` |
|
||||
| A request or response contract that providers implement | `interfaces/<interface>/types.ts` |
|
||||
| A vendor's endpoint, request shape, catalog, prices, or quirks | `providers/<vendor>/<interface>/` |
|
||||
| A vendor's auth, REST plumbing, or constants that two of its interfaces use | `providers/<vendor>/` |
|
||||
| Behavior of a wire protocol that several vendors speak | `protocols/<protocol>/` |
|
||||
| A helper that two or more interfaces use and that belongs to no vendor | `shared/` |
|
||||
| A helper that non-AI drivers or controllers also use | `src/backend/drivers/util/` |
|
||||
| AI code that a service also uses | `src/backend/util/`, in a file named `ai<Something>.ts` |
|
||||
|
||||
Keep a helper next to its only consumer. Move it up when a second consumer appears, not before.
|
||||
|
||||
When one Puter option becomes a different request shape per vendor, the interface reads the option and each dialect maps it. The chat `compaction` option is read in `interfaces/chat/utils/compaction.js`, mapped for OpenAI by `toOpenAiContextManagement` in `protocols/openaiCompatible/openaiParams.ts`, and mapped for Anthropic by `toAnthropicContextManagement` in `providers/claude/chat/contextManagement.ts`. Tools work the same way: `normalize_tools_object` in the interface, then `toolsToWire` for OpenAI and `claudeToolPolicy` for Anthropic.
|
||||
|
||||
Tests sit next to the code they test. Fixtures go in a `fixtures/` folder next to the test that reads them.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Create `providers/<vendor>/<interface>/`. Use the vendor's existing folder if it has one, so all of a vendor's interfaces stay together.
|
||||
2. Implement the interface's contract from `interfaces/<interface>/types.ts`:
|
||||
|
||||
| Interface | Contract | Base class |
|
||||
| --- | --- | --- |
|
||||
| chat | `IChatProvider` | `OpenAICompatProvider` for an OpenAI-compatible vendor |
|
||||
| image | `IImageProvider` | none |
|
||||
| video | `IVideoProvider` | `VideoProvider` |
|
||||
| textToSpeech | `ITTSProvider` | none |
|
||||
| speechToText | `ISpeechToTextProvider` | `SpeechToTextProvider` |
|
||||
| ocr | `IOcrProvider` | none |
|
||||
|
||||
Speech to speech has one provider, so `VoiceChangerDriver` uses `ElevenLabsSpeechToSpeechProvider` directly. Add a contract to its `types.ts` when a second provider needs one.
|
||||
|
||||
3. Put the model catalog and prices in the same folder, as `models.ts` or `costs.ts`. A provider that loads its catalog from the vendor wraps the fetch in `cachedRemoteCatalog` from `shared/`.
|
||||
4. Register the provider in the interface's driver: in `#registerProviders()`, or in `onServerStart()` for OCR and speech to speech. A chat provider built from an API key and an optional base URL is one row in `KEYED_PROVIDERS` instead. Text to speech and speech to text also list the provider id and its aliases in `providerAliases.ts`.
|
||||
5. Read credentials from `config.providers[<key>]`. The key is part of the self-hosting config contract, so reuse the vendor's existing key when it has one.
|
||||
6. Add tests next to the provider. Mock the vendor SDK or `fetch` at the network boundary and drive the provider through the real driver where you can. A live test is a `*.integration.test.ts` file that skips itself, with `skipUnlessEnv` from [testEnv.ts](../src/backend/testEnv.ts), unless its `PUTER_TEST_AI_*` variable is set. Helpers for provider tests are in [shared/integrationTestUtil.ts](../src/backend/drivers/ai/shared/integrationTestUtil.ts).
|
||||
7. For a chat provider, add its static catalog to `CATALOGS` in [modelCatalogs.test.ts](../src/backend/drivers/ai/interfaces/chat/modelCatalogs.test.ts) and the provider to [providerConsistency.test.ts](../src/backend/drivers/ai/interfaces/chat/providerConsistency.test.ts). The catalog test fails when a `providers/*/chat/` file with "model" in its name exports a catalog that isn't listed.
|
||||
8. Add the provider's config key to the `providers` config of the interface's [fuzz suite](#fuzz-tests), and its id to the list of providers the suite expects.
|
||||
9. Update the published docs for the models and limits the provider adds, following [contributing-apis.md](contributing-apis.md).
|
||||
|
||||
### OpenAI-compatible vendors
|
||||
|
||||
A vendor that speaks the OpenAI Chat Completions protocol extends `OpenAICompatProvider` and overrides only what differs. It still gets its own `providers/<vendor>/chat/` folder.
|
||||
|
||||
Vendors share one constants-only folder only when at least six of them differ in nothing but base URL and model catalog. Each then becomes one data file under `providers/openaiCompatible/providers/`, read by a single implementation. No vendor qualifies today, because every subclass overrides request, catalog, or billing behavior.
|
||||
|
||||
## Fuzz tests
|
||||
|
||||
Two seeded suites send malformed input through the real drivers and providers. They run with the rest of the backend tests.
|
||||
|
||||
| Suite | Covers |
|
||||
| --- | --- |
|
||||
| [interfaces/chat/ChatCompletionDriver.fuzz.test.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts) | Chat requests with a few values changed, and valid requests against wrong statuses, bodies of the wrong shape, and broken event streams. |
|
||||
| [interfaces/mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts) | Image, video, speech and OCR requests with a few values changed. Every vendor refuses the request. |
|
||||
|
||||
What the suites assert:
|
||||
|
||||
- A malformed request never fails with an error that has no status, which `/drivers/call` would report as an unclassified 500.
|
||||
- For chat, with the vendors answering, a malformed request also never fails with a 5xx and never marks a route unhealthy.
|
||||
- When the upstream misbehaves, chat ends with an answer or an `HttpError`. A stream only has to end: the error line a stream carries isn't checked, so a malformed event can still end one with a 500.
|
||||
|
||||
The vendors are played at the HTTP boundary by `upstream` in [shared/fuzzTestUtil.ts](../src/backend/drivers/ai/shared/fuzzTestUtil.ts), which stands in for global `fetch`, undici `fetch` and axios, and makes `https.request` throw so an SDK with its own client fails the suite. The vendors' SDKs run, except Groq's, which is swapped for the OpenAI SDK, and the AWS clients, whose `send` is stubbed. The media suite calls the drivers as a user with unlimited metering, so a request gets past file input and the credit hold to the vendor. A provider that loads its catalog from the vendor registers no models there, so the suites skip it.
|
||||
|
||||
A failure lists each distinct finding with its case number and input. The random source is seeded, so the same case fails again on the next run. To search further, raise the case count:
|
||||
|
||||
```sh
|
||||
PUTER_FUZZ_CASES=100000 npx vitest run --config src/backend/vitest.config.ts src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts
|
||||
```
|
||||
|
||||
When a suite finds a crash, validate the value where the request enters, in the driver or in `normalize_single_message`, so every provider is covered. Reject only what crashed: a value a provider ignored or refused before must still reach it. An option a driver reads as text, a number or a flag goes in the driver's `SCALAR_OPTIONS`, and `assertScalarOptions` from `shared/scalarOptions.ts` answers 400 for a value there that can't be converted to text. Add a unit test for the case next to the fix.
|
||||
|
||||
## Adding an interface
|
||||
|
||||
1. Create `interfaces/<interface>/` with the driver class and a `types.ts` for the contracts providers implement.
|
||||
2. Register the driver in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts).
|
||||
3. Use `AI_RATE_LIMIT` and `AI_CONCURRENT` from `shared/aiLimits.ts`, and publish any new limit in [rate-limits-and-quotas.md](../src/docs/src/rate-limits-and-quotas.md).
|
||||
4. Meter through `withAiCostFactor` from `src/backend/util/aiCostFactor.ts`, and route between providers with `ProviderRegistry` from `shared/providerRegistry.ts`.
|
||||
5. List the options the providers read as text, a number or a flag in a `SCALAR_OPTIONS` array, and call `assertScalarOptions` from `shared/scalarOptions.ts` first in each method. Add the driver to [mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts).
|
||||
6. Follow [contributing-apis.md](contributing-apis.md) for the puter.js method, types, docs, and tests.
|
||||
@@ -42,6 +42,19 @@ block-beta
|
||||
|
||||
Extensions sit alongside this stack and can register into any layer; see [Extensions](#extensions).
|
||||
|
||||
### AI drivers
|
||||
|
||||
The AI drivers have their own layout under [src/backend/drivers/ai/](../src/backend/drivers/ai/):
|
||||
|
||||
| Folder | Holds |
|
||||
| --- | --- |
|
||||
| `interfaces/` | The Puter-facing drivers, one folder per AI interface (chat, image, video, text to speech, speech to text, speech to speech, OCR). |
|
||||
| `providers/` | Vendor code, as `providers/<vendor>/<interface>/`. |
|
||||
| `protocols/` | Wire protocols that several vendors speak, such as the OpenAI-compatible one. |
|
||||
| `shared/` | Helpers that more than one interface uses. |
|
||||
|
||||
A call goes from an interface's driver to a provider, and from there to a shared protocol when the vendor speaks one. [ai-drivers.md](ai-drivers.md) has the ownership rules, the import rules, and the steps for adding a provider.
|
||||
|
||||
## Entry point: `PuterServer`
|
||||
|
||||
`PuterServer` is the bootstrap. It:
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# Embedded app browser
|
||||
|
||||
`GET /embed/apps` on the deployment's main GUI origin renders the same app browser
|
||||
as the dashboard's Apps tab, without the desktop or dashboard navigation.
|
||||
|
||||
It supports search, paging, saved folders, and saved ordering. It uses the same
|
||||
installed-app and recommendation data as the dashboard, including the user's
|
||||
removed recommendations. Editing, adding, reordering, renaming, and uninstalling
|
||||
are disabled. Selecting an app opens it in a new browser tab at its own URL, the
|
||||
same way the dashboard does, and the host page stays open. The app's token isn't
|
||||
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
|
||||
embed must still be open then. If the iframe is removed first, the app falls back
|
||||
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
The embed follows the OS's light or dark setting. A host with a fixed theme adds
|
||||
`theme=dark` or `theme=light` to the fragment (`#user=<uuid>&theme=dark`), since
|
||||
Chromium doesn't pass an iframe's `color-scheme` on to the page inside it.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
The embed shows the GUI session only to the user the host page is signed in as.
|
||||
The host names that user's `uuid` (from `puter.auth.getUser()`) in the iframe
|
||||
URL's fragment, as `/embed/apps#user=<uuid>`. The embed reads the session, asks
|
||||
`/whoami` which user it belongs to, and shows the signed-out view unless the two
|
||||
match. So a host that names no user, a malformed one, or a user other than the
|
||||
session's, gets the signed-out view, and so does a session the API refuses. A
|
||||
failed `/whoami` check shows the error state. The fragment never reaches the
|
||||
server and can only narrow what the embed shows: naming the session's own user
|
||||
gives the same view as a direct visit to the GUI. Changing the fragment of a
|
||||
loaded embed reloads it.
|
||||
|
||||
The embed reads the existing GUI session from local storage on its own origin.
|
||||
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
|
||||
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
|
||||
local storage under each top-level origin, so even a sibling subdomain's iframe
|
||||
can't see the GUI's stored token. The session cookie is still sent, because the
|
||||
host is on the same site. The embed doesn't write that token back to storage. It
|
||||
does not create accounts, show a login dialog, or accept a token from its parent.
|
||||
Being signed in on a subdomain alone does not create a session on the main GUI
|
||||
origin.
|
||||
|
||||
Without a session it can show, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
that the server put in the page, and nothing else: no installed apps, folders, or
|
||||
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
|
||||
launch uses, with no opener and no token on offer, so the app signs in on its own.
|
||||
Apps that need the desktop open `/app/<name>`. If the page carries no recommended
|
||||
apps, or a load fails, the embed displays an unavailable/error state.
|
||||
|
||||
The route's `Content-Security-Policy: frame-ancestors` allows the GUI origin and
|
||||
the embedding page's origin when it is exactly one label under `config.domain`
|
||||
with the scheme and port from `config.origin`. Subdomains the server answers
|
||||
itself (`api`, `dav`, `js`, `www`, the native-app hosts, and the host of
|
||||
`api_base_url`) and the user-content hosting domains (`static_hosting_domain`,
|
||||
`private_app_hosting_domain`, and their `_alt` variants) are refused. For
|
||||
example, `https://puter.com` permits `https://spreadsheet.puter.com` but not
|
||||
`https://api.puter.com`, and `http://puter.localhost:4100` permits
|
||||
`http://spreadsheet.puter.localhost:4100` but not
|
||||
`http://someone.site.puter.localhost:4100`. The embedding page is read from the
|
||||
iframe request's referrer, so the host must not suppress it (the browser default
|
||||
`strict-origin-when-cross-origin` is enough). All ancestors in a nested iframe
|
||||
chain must be allowed. Other GUI routes retain `X-Frame-Options: SAMEORIGIN`.
|
||||
|
||||
The embed accepts no query parameters. Requests with a query string redirect to
|
||||
the same embed path so SDK bootstrap parameters cannot override the session or API
|
||||
origin. The host needs to allow the GUI origin in its own `frame-src` CSP if it
|
||||
sets one. If sandboxing the iframe, allow scripts, same-origin storage, popups,
|
||||
and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
A first-party app that signs in with puter.js can place `<puter-user-menu>` from
|
||||
the SDK in its header instead (see `src/puter-js/src/ui/components.md`). It opens
|
||||
this embed with the signed-in user named and closes it when the app's sign-in
|
||||
changes. The rest of this section is for a host that builds the iframe itself.
|
||||
|
||||
The iframe can be shown whether or not the user is signed in. Create it when
|
||||
“More apps” is opened, naming the signed-in user in its fragment, and remove it
|
||||
on close so reopening loads current apps and preferences. Remove it when the
|
||||
host's own sign-in changes too, such as on sign-out, so an open embed doesn't
|
||||
keep the previous user. The host owns the modal and close control;
|
||||
there is no parent messaging API and no app list or credential is sent to it.
|
||||
The app browser fills its iframe and responds to its size. It shows a spinner until its apps load.
|
||||
|
||||
This example assumes `puterOrigin` comes from the host app's deployment
|
||||
settings. Translate the button and frame labels using the host app's
|
||||
localization system.
|
||||
|
||||
```html
|
||||
<button id="more-apps" type="button">More apps</button>
|
||||
<dialog id="apps-dialog" aria-label="More apps">
|
||||
<button id="close-apps" type="button">Close</button>
|
||||
<div id="apps-frame"></div>
|
||||
</dialog>
|
||||
<style>
|
||||
#apps-dialog { padding: 12px; width: min(900px, 90vw); }
|
||||
#apps-frame iframe { width: 100%; height: min(650px, 75vh); border: 0; }
|
||||
</style>
|
||||
<script type="module">
|
||||
const puterOrigin = 'https://puter.com'; // Use this deployment's configured origin.
|
||||
const dialog = document.querySelector('#apps-dialog');
|
||||
const container = document.querySelector('#apps-frame');
|
||||
const trigger = document.querySelector('#more-apps');
|
||||
|
||||
trigger.addEventListener('click', async () => {
|
||||
const url = new URL('/embed/apps', puterOrigin);
|
||||
// Signed out, the embed shows the recommended apps.
|
||||
const user = puter.auth.isSignedIn() ? await puter.auth.getUser().catch(() => null) : null;
|
||||
if (user) url.hash = `user=${encodeURIComponent(user.uuid)}`;
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = 'Your apps';
|
||||
frame.src = url.href;
|
||||
container.replaceChildren(frame);
|
||||
dialog.showModal();
|
||||
});
|
||||
document.querySelector('#close-apps').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => {
|
||||
container.replaceChildren();
|
||||
trigger.focus();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
|
||||
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
|
||||
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
|
||||
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
|
||||
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
|
||||
`embedHostTheme.js` beside it applies the host's theme.
|
||||
The normal GUI webpack build emits `bundle.min.js`, `apps-embed.min.js`, and `feedback-embed.min.js`.
|
||||
The `/embed/apps` route puts the recommended list in the page's config as
|
||||
`guestApps`. No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
## Feedback dialog
|
||||
|
||||
`GET /embed/feedback` is the app feedback dialog (`puter.ui.showFeedbackDialog()`)
|
||||
for a first-party page, with the same framing rules, query redirect, and
|
||||
host-user binding as `/embed/apps`. It sends the feedback for the framing
|
||||
page's origin, which the server reads from the referrer and pins with
|
||||
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
|
||||
so the dialog's own scrim covers the host.
|
||||
|
||||
puter.js uses it on a page one label under the GUI origin, other than the
|
||||
subdomains the server refuses, when the page is signed in, framing it in a
|
||||
modal `<dialog>` that stays hidden until the embed is ready. A session check that takes longer than 200ms shows a loading scrim
|
||||
with a spinner in its place; Escape or a click on it cancels. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
|
||||
(`loading`, then `ready` or `unavailable`) and finally
|
||||
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
|
||||
named user's session, or while that user's email still needs confirming, and
|
||||
puter.js then uses the `/action/send-feedback` popup, which can sign in. So does
|
||||
a frame that never answers, such as one the host's CSP blocks. `feedbackEmbed.js`
|
||||
is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
|
||||
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
|
||||
is the host side.
|
||||
|
||||
## Sign-in and file pickers
|
||||
|
||||
`GET /embed/sign-in`, `/embed/show-open-file-picker`, `/embed/show-save-file-picker`
|
||||
and `/embed/show-directory-picker` serve the same GUI popups as `/action/<action>`
|
||||
for a first-party page, with the same framing rules, query redirect, and
|
||||
`no-store` as `/embed/apps`. Any other spelling of the path (another case, a
|
||||
trailing slash) redirects to it, since the shell boots the desktop for it. They render the normal GUI shell, which names the
|
||||
framing page (read from the referrer, pinned with `frame-ancestors`) as
|
||||
`gui_params.embed_host_origin`. The GUI then runs the popup boot unchanged, with
|
||||
the framing page as `window.opener`, and `window.close()` asks the page to
|
||||
remove the frame (`src/gui/src/helpers/popupFrame.js`). Without a framing page
|
||||
to answer (opened top-level, or framed by a refused page) it does nothing. The
|
||||
popup's parameters come from the fragment instead of the query: `msg_id`,
|
||||
`options` and `request_auth`, plus `user` for a picker.
|
||||
|
||||
The frame posts `{ msg: 'puterPopupFrame', state }` to its parent: `loading` as
|
||||
soon as the shell boots, `ready` once it shows a window or dialog, `unavailable`
|
||||
when the page has to use the popup, `provider` when the user picks a sign-in
|
||||
provider (below), and `closed` where a popup would close. Its
|
||||
answers (`puter.token`, `fileOpenPicked`, `directoryPicked`, `fileSaved`, the
|
||||
cancel messages, `sendMeFileData`) are the popup's.
|
||||
|
||||
puter.js uses them on a page one label under the GUI origin, other than the
|
||||
subdomains the server refuses, unless the page is cross-origin isolated or in a
|
||||
frame of another site (which `frame-ancestors` refuses too).
|
||||
`showPopupFrame` in `src/puter-js/src/lib/guiEmbed.js` shows the frame in a box
|
||||
the size of the popup window, over a scrim, hidden until `ready` and behind the
|
||||
loading spinner after 200ms. Escape closes it, on the page or in the frame. One shows per popup, as each
|
||||
popup has its own window name: a second picker request of the same kind
|
||||
replaces the one showing, and a `signIn()` call made while a framed sign-in is
|
||||
open settles with it. On
|
||||
screens up to 600px wide the box fills the screen. A frame that is refused,
|
||||
never answers, or reports `unavailable` falls back to the popup, opened through
|
||||
a consent dialog when the click that asked for it has expired. A reload or navigation the frame
|
||||
starts itself (logging out, a sign-out in another tab) names the frame as its
|
||||
referrer, so the server refuses it; the page then shows its spinner and opens
|
||||
the frame again, once per `ready`.
|
||||
|
||||
- **Sign-in** (`puter.auth.signIn()`) has no host-user binding: it is how the
|
||||
page gets a user. The box has a close button, which rejects with
|
||||
`auth_window_closed` as closing the popup does. Providers refuse to be
|
||||
framed, so choosing one posts `{ state: 'provider', url }` with the
|
||||
provider's start URL for this sign-in's `msg_id`, then `unavailable`. The
|
||||
page opens the popup straight at that URL, once it checks the URL is the
|
||||
GUI's `/auth/oidc/<provider>/start` for this `msg_id`, and the provider
|
||||
returns it as this sign-in's popup. `attempt_temp_user_creation` keeps the popup, since
|
||||
only the popup creates a temporary account.
|
||||
- **Pickers** frame only for a signed-in page. The GUI reports `unavailable`
|
||||
unless its session belongs to the user named in `user`, and the page then uses
|
||||
the popup, which can sign in and switch accounts. The token the GUI sends
|
||||
before a picker's answer isn't adopted, since it is for the page's own user.
|
||||
Dismissing the loading scrim settles the picker as cancelled.
|
||||
|
||||
## Upgrade
|
||||
|
||||
`GET /embed/upgrade` is the deployment's upgrade dialog for a first-party page,
|
||||
served like the popups above: the GUI shell with the same framing rules, query
|
||||
redirect, exact-path redirect and `no-store`, naming the framing page as
|
||||
`gui_params.embed_host_origin`. It runs only when framed by an allowed page.
|
||||
|
||||
puter.js frames it on a signed-in first-party page (the same pages as the
|
||||
pickers) for `puter.ui.requestUpgrade(details)`, and for the Upgrade button of
|
||||
the usage limit dialog a refused call shows there. Elsewhere both keep their
|
||||
behavior: inside an app `requestUpgrade()` asks the desktop, and on any other
|
||||
page the usage limit dialog opens the dashboard's upgrade page in a new tab.
|
||||
|
||||
The frame covers the page with no box or close button, and the GUI page is
|
||||
transparent (`html.popup-frame-transparent`) so the dialog's own scrim covers
|
||||
the host. The frame allows `payment` as well as `otp-credentials`. Escape on the
|
||||
page is left to the dialog, since one there shouldn't drop a payment in
|
||||
progress. Its fragment names `user` (the page's signed-in user) and, when the
|
||||
refusal gave them, `reason` (`funds`, `subscription` or `storage`) and `method`.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting (which the frame couldn't be
|
||||
closed from), and the deployment defines `window.UIUpgradeAccount` with
|
||||
`UIUpgradeAccount.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UIUpgradeAccount({ attribution: { appUid, reason, method }, onClose }).open_as_window();
|
||||
```
|
||||
|
||||
`appUid` is the app the framing page's origin resolves to, never one the
|
||||
fragment names. `open_as_window()` resolves once the dialog shows, and the GUI
|
||||
then posts `ready`. While the page still hides the frame, which Chromium and
|
||||
WebKit lay out at 0x0, the GUI posts `ready` first and opens the dialog once
|
||||
the frame has a viewport, so a window it opens doesn't center on 0x0.
|
||||
`onClose({ upgraded })` 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', upgraded }` and the page removes
|
||||
the frame. If `open_as_window()` rejects, the GUI posts `unavailable` instead. `requestUpgrade()` resolves `{ upgraded }`. A call made while the
|
||||
frame shows settles with it rather than replacing it, and a call refused while
|
||||
it shows still rejects but opens no usage limit dialog over it.
|
||||
|
||||
When the frame can't show the upgrade (refused, never ready, `unavailable`, or
|
||||
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
|
||||
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/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
|
||||
```
|
||||
@@ -63,6 +63,9 @@ store:
|
||||
sealed cursor: the last `id` it returned, and the last `(replaced_at, id)`
|
||||
among rows up to that id. Each is its own indexed read, merged into one
|
||||
page, so a row replaced after a client read it comes back from that cursor.
|
||||
`from=end` with no cursor answers an empty page whose cursor holds the
|
||||
slice's newest `id` and the slice's latest `(replaced_at, id)` as the
|
||||
replacement mark.
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
+13
-12
@@ -1,6 +1,6 @@
|
||||
# Browser push client
|
||||
|
||||
Puter's desktop shows pushes through `/sw.js`. Core registers it, and shows Settings → Notifications, only when the `pushEnabled` gui param is `true`; core never sets it. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
|
||||
Puter's desktop shows pushes through `/sw.js`. Core shows Settings → Notifications only when the `pushEnabled` gui param is `true`; core never sets it. The worker is registered only when push is turned on in the browser, and a load removes one left without a subscription. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
|
||||
|
||||
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
|
||||
|
||||
@@ -16,18 +16,19 @@ 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
|
||||
|
||||
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
|
||||
|
||||
An answer carries `visibilityState`, `focused` and `showsOwn`, the last being whether the page shows the notification itself. A website page shows it itself when it is hidden or unfocused, has an `onPush()` handler and notification permission, and no other tab of the same site is focused. Inside the desktop, the desktop shows it, and an app's `onPush()` result is never shown as a browser notification.
|
||||
An answer carries `visibilityState` and `focused` as they were when the page received the notification. Pages never show a browser notification themselves; pushes do.
|
||||
|
||||
- An answer from a page that is visible and focused, or that shows the notification itself, stops the push.
|
||||
- Otherwise the notification is pushed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
|
||||
- A hidden desktop tab shows no banner, so answers only from hidden desktop tabs don't stop a push of Puter's own notifications.
|
||||
- A client that can't answer, such as an older puter.js, holds the push back while it is connected.
|
||||
- An answer from a page that was visible and focused stops the push (`handled` on `notif.delivery.settled`). An older client that doesn't send `focused` counts while visible.
|
||||
- Otherwise the notification is pushed, to every browser the user subscribed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
|
||||
- A client that can't answer, such as an older puter.js, doesn't hold the push back.
|
||||
|
||||
The ask reaches only the sockets in the asking region. So that other regions hear about a page too, a page that receives a notification live while visible and focused also sends `notif.handled` with `{ uid }` on the socket it came on. Core relays it as the node-local `notif.handled` event with the socket's user and app, at most 60 a minute per user; an extension that delivers pushes decides what to do with it.
|
||||
|
||||
## Registering
|
||||
|
||||
@@ -58,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;
|
||||
@@ -68,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
+107
-105
@@ -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"
|
||||
@@ -9555,9 +9548,9 @@
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -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",
|
||||
@@ -9869,9 +9862,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
|
||||
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
||||
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -14244,10 +14237,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.1.tgz",
|
||||
"integrity": "sha512-N86EnYOxZjW1uHQIgSXnnRdnJz+wxmtDsnk3C4R/9rQ1utj8A2znauXZ3Q3+stCHxG5ckY4/NrO09WiPU6rcdQ==",
|
||||
"license": "MIT-0"
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-4.2.0.tgz",
|
||||
"integrity": "sha512-QG5Rh8wY/zjwFF1lKWcBbwWcAWR5jWodmMPL4gMik43XDLebtWoAAPyG0lrE2kYYZ5IHTASrepGuGO0YIwNKAg==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
@@ -14525,9 +14521,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.17.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
|
||||
"integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -15996,9 +15992,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/systeminformation": {
|
||||
"version": "5.33.1",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz",
|
||||
"integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==",
|
||||
"version": "5.33.19",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.19.tgz",
|
||||
"integrity": "sha512-Zm7wGeCG7PqfvzegMJIzrxICopWqYCeLATVmvxOOci1isLcNVKgY4Fr0hhYQzvc7cgtK3377jQVChTFAnznOiA==",
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
@@ -16584,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"
|
||||
@@ -17999,16 +17995,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/undici": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrangler/node_modules/workerd": {
|
||||
"version": "1.20260915.1",
|
||||
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
|
||||
@@ -18148,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",
|
||||
@@ -18240,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"
|
||||
@@ -18307,11 +18300,11 @@
|
||||
"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",
|
||||
"postal-mime": "^3.0.1",
|
||||
"postal-mime": "^4.2.0",
|
||||
"replicate": "^1.0.0",
|
||||
"sharp": "^0.35.5",
|
||||
"smtp-server": "^3.19.24",
|
||||
@@ -18319,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",
|
||||
@@ -18350,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"
|
||||
@@ -19130,7 +19132,7 @@
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
"open": "^10.2.0",
|
||||
"path-browserify": "1.0.1",
|
||||
"postal-mime": "^3.0.1",
|
||||
"postal-mime": "^4.2.0",
|
||||
"socket.io-client": "^4.8.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -285,6 +317,18 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the database clock as epoch milliseconds in each dialect', () => {
|
||||
expect(sqlite().nowMsExpr()).toBe(
|
||||
"CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
);
|
||||
expect(mysql().nowMsExpr()).toBe(
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
);
|
||||
expect(postgres().nowMsExpr()).toBe(
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes a stored timestamp the way each driver reads it back', () => {
|
||||
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
|
||||
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
|
||||
@@ -306,3 +350,13 @@ describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('builds an insert for batchWrite and shares it with insert', async () => {
|
||||
const client = new FixtureDatabaseClient([]);
|
||||
const entry = client.insertEntry('widget', { name: "reader's", enabled: true });
|
||||
expect(entry.values).toEqual(["reader's", true]);
|
||||
expect(entry.statement).toContain('VALUES (?, ?)');
|
||||
await client.insert('widget', { name: "reader's", enabled: true });
|
||||
expect(client.writes).toEqual([{ query: entry.statement, params: entry.values }]);
|
||||
});
|
||||
@@ -110,14 +110,23 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
tableName: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<WriteResult> {
|
||||
const entry = this.insertEntry(tableName, data);
|
||||
return this.write(
|
||||
entry.statement + this.returningIdClause(),
|
||||
entry.values,
|
||||
);
|
||||
}
|
||||
|
||||
/** An insert that can join other writes in a transaction. */
|
||||
insertEntry(tableName: string, data: Record<string, unknown>): BatchEntry {
|
||||
const cols = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const sql =
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})` +
|
||||
this.returningIdClause();
|
||||
return this.write(sql, values);
|
||||
return {
|
||||
statement:
|
||||
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
|
||||
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
|
||||
`VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
values: Object.values(data),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `read()` goes to a separate replica rather than the primary. */
|
||||
@@ -163,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
|
||||
@@ -401,6 +438,21 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The database's own clock as epoch milliseconds, so nodes whose clocks
|
||||
* disagree still agree on it. The mysql form ignores the session time zone,
|
||||
* so a DST change can't shift it.
|
||||
*/
|
||||
nowMsExpr(): string {
|
||||
return this.case({
|
||||
sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
|
||||
postgres:
|
||||
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
|
||||
otherwise:
|
||||
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What a read of a timestamp column holding `seconds` returns: sqlite gives
|
||||
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
|
||||
|
||||
@@ -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 = 95;
|
||||
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(
|
||||
@@ -724,6 +783,85 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps notification.send_key unique', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
expect(
|
||||
rows.find((r) => r.name === 'idx_notification_send_key'),
|
||||
).toMatchObject({ unique: 1 });
|
||||
const columns = await client.read(
|
||||
'PRAGMA index_info(`idx_notification_send_key`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
|
||||
});
|
||||
|
||||
it('creates event_user_handlers with its unique and holder indexes', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_user_handlers`)',
|
||||
);
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'holder_user_id',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'scope_since',
|
||||
]);
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_user_handlers`)',
|
||||
);
|
||||
expect(
|
||||
indexes.find(
|
||||
(i) => i.name === 'idx_event_user_handlers_app_holder_name',
|
||||
),
|
||||
).toMatchObject({ unique: 1 });
|
||||
expect(
|
||||
indexes.find((i) => i.name === 'idx_event_user_handlers_holder_app'),
|
||||
).toMatchObject({ unique: 0 });
|
||||
const columnsOf = async (index: string) =>
|
||||
(await client.read(`PRAGMA index_info(\`${index}\`)`)).map((c) =>
|
||||
String(c.name),
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_app_holder_name')).toEqual(
|
||||
['app_uid', 'holder_user_id', 'name'],
|
||||
);
|
||||
expect(await columnsOf('idx_event_user_handlers_holder_app')).toEqual([
|
||||
'holder_user_id',
|
||||
'app_uid',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves event_handlers as it was', async () => {
|
||||
const columns = await client.read('PRAGMA table_info(`event_handlers`)');
|
||||
expect(columns.map((c) => String(c.name))).toEqual([
|
||||
'id',
|
||||
'app_uid',
|
||||
'name',
|
||||
'source',
|
||||
'source_hash',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds event_subscriptions.handler_scope, app scope for existing rows', async () => {
|
||||
const columns = await client.read(
|
||||
'PRAGMA table_info(`event_subscriptions`)',
|
||||
);
|
||||
expect(columns.find((c) => c.name === 'handler_scope')).toMatchObject({
|
||||
notnull: 1,
|
||||
dflt_value: '0',
|
||||
});
|
||||
const indexes = await client.read(
|
||||
'PRAGMA index_list(`event_subscriptions`)',
|
||||
);
|
||||
expect(indexes.map((i) => String(i.name))).toContain(
|
||||
'idx_event_subscriptions_holder',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an already-migrated database untouched on a second boot', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
|
||||
const path = join(dir, 'nested', 'puter.sqlite');
|
||||
|
||||
@@ -134,6 +134,12 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[92, ['0097_user-to-user-holder-permission-index.sql']],
|
||||
[93, ['0098_notification-groups.sql']],
|
||||
[94, ['0099_kv-share-handle-notify.sql']],
|
||||
[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,43 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
|
||||
-- per-file applied-state tracking, so every step is guarded and a replay does
|
||||
-- nothing.
|
||||
|
||||
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_send_key_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_send_key'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
|
||||
(`send_key`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_send_key_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, kept apart from
|
||||
-- `event_handlers` so code that predates them never reads them. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- and a guarded column add. There is no per-file applied-state tracking, so a
|
||||
-- replay has to be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
`name` varchar(128) NOT NULL,
|
||||
`source` mediumtext NOT NULL,
|
||||
`source_hash` char(64) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`updated_at` bigint NOT NULL,
|
||||
-- When the scope first held a handler; kept across handler churn, so the
|
||||
-- rent sweep sees the scope's age, not its oldest remaining handler's.
|
||||
`scope_since` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_user_handlers_app_holder_name` (`app_uid`, `holder_user_id`, `name`),
|
||||
KEY `idx_event_user_handlers_holder_app` (`holder_user_id`, `app_uid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Guarded here rather than through `_puter_add_col`, so the file also applies
|
||||
-- on its own to a database that never ran the earlier migrations.
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_event_subscriptions_handler_scope()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'handler_scope'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD COLUMN `handler_scope` tinyint unsigned NOT NULL DEFAULT 0,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_event_subscriptions_handler_scope();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `notification` (`user_id`, `audience`, `app_uid`, `id`). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so the index is added only when missing and a
|
||||
-- replay does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_scope_id_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_scope_id'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_scope_id`
|
||||
(`user_id`, `audience`, `app_uid`, `id`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_notification_scope_id_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
|
||||
@@ -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,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/>.
|
||||
|
||||
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
|
||||
-- rationale. Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
|
||||
ON notification (send_key);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope. See
|
||||
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_user_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
holder_user_id INTEGER NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
source_hash VARCHAR(64) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
scope_since BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_user_handlers_app_holder_name
|
||||
ON event_user_handlers (app_uid, holder_user_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_user_handlers_holder_app
|
||||
ON event_user_handlers (holder_user_id, app_uid);
|
||||
|
||||
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS handler_scope smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 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/>.
|
||||
|
||||
-- notification (user_id, audience, app_uid, id). See
|
||||
-- sqlite/0102_notification-scope-id-index.sql for the rationale. Idempotent via
|
||||
-- IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_scope_id
|
||||
ON notification (user_id, audience, app_uid, id);
|
||||
@@ -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/>.
|
||||
|
||||
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
|
||||
-- schedule) that wrote this row or last replaced it, derived from the
|
||||
-- schedule and its slot. Unique, so one slot can never write two rows, and a
|
||||
-- send that crashed after writing finds its row again by it. Null on every row
|
||||
-- a delayed send never touched.
|
||||
|
||||
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
|
||||
ON `notification` (`send_key`);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Handlers an app's user published into their own scope, in a table of their
|
||||
-- own so `event_handlers` stays the app owner's set. Same columns as
|
||||
-- `event_handlers` (see 0076_event-handlers.sql) plus the holder.
|
||||
--
|
||||
-- - `holder_user_id` : the user whose scope this is. A name is unique per
|
||||
-- (app, holder), so the same name can live in the app's
|
||||
-- set and in each holder's.
|
||||
-- - `(holder_user_id, app_uid)` : a holder's scopes, for listing and for the
|
||||
-- rent count.
|
||||
--
|
||||
-- `event_subscriptions.handler_scope` says which table a row's `handler_name`
|
||||
-- refers to: 0 the app's set, 1 the row holder's scope.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"holder_user_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"source_hash" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"updated_at" INTEGER NOT NULL,
|
||||
"scope_since" INTEGER NOT NULL -- unix seconds; the scope's first handler, kept across churn
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_user_handlers_app_holder_name`
|
||||
ON `event_user_handlers` (`app_uid`, `holder_user_id`, `name`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_user_handlers_holder_app`
|
||||
ON `event_user_handlers` (`holder_user_id`, `app_uid`);
|
||||
|
||||
ALTER TABLE `event_subscriptions` ADD COLUMN `handler_scope` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- Index a mailbox slice by row id, so a fetch finds the newest row of a slice,
|
||||
-- and pages forward through one, with a seek rather than a scan of the slice.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_scope_id`
|
||||
ON `notification` (`user_id`, `audience`, `app_uid`, `id`);
|
||||
@@ -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';
|
||||
|
||||
@@ -117,7 +118,7 @@ export type EventMap = {
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
intended_service: string;
|
||||
model_details: {
|
||||
model_details?: {
|
||||
id: string;
|
||||
provider: string;
|
||||
input_cost_key: string;
|
||||
@@ -200,6 +201,16 @@ export type EventMap = {
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
/**
|
||||
* Which signup methods are open, emitted via `emitAndWait` before every
|
||||
* signup and whenever the GUI reads the provider list. A listener sets
|
||||
* `oidcOnly` to refuse form and temp signups, leaving OIDC and team
|
||||
* provisioning open; `message` is what a refused form signup is told.
|
||||
*/
|
||||
'puter.signup.policy': {
|
||||
oidcOnly: boolean;
|
||||
message: string | null;
|
||||
};
|
||||
'puter.signup.validate': {
|
||||
allow: boolean;
|
||||
email?: string;
|
||||
@@ -230,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.
|
||||
@@ -237,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': {
|
||||
@@ -288,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.
|
||||
@@ -323,6 +344,8 @@ export type EventMap = {
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
// Browser-signals dispatch id the client sent, or null.
|
||||
dispatch_id: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
// With `allowed` false, offer the card fallback in place of SMS. Only
|
||||
@@ -408,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 };
|
||||
@@ -544,6 +571,14 @@ export type EventMap = {
|
||||
'kv.share.retired': { handles: string[]; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/** An app message was acknowledged after its state committed. */
|
||||
'notif.acknowledged': {
|
||||
userId: number;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
appUid: string;
|
||||
};
|
||||
/**
|
||||
* A notification row exists. Emitted after the insert commits — nothing
|
||||
* pushes a uid before there is a row to name — and carries the scope tuple
|
||||
@@ -577,8 +612,12 @@ export type EventMap = {
|
||||
value: Record<string, unknown>;
|
||||
/** An earlier notification rewritten in place. */
|
||||
updated?: true;
|
||||
/** Replaced an earlier notification in its group. */
|
||||
replaced?: true;
|
||||
/** Names this one send, for a receipt asked about it. */
|
||||
sendId: string;
|
||||
/** The same `sendId` may be emitted again, as a delayed send is. */
|
||||
replayable?: true;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
@@ -612,6 +651,18 @@ export type EventMap = {
|
||||
/** A peer region holds a socket for the user. */
|
||||
peerRegions: boolean;
|
||||
responders: NotifDeliveryResponder[];
|
||||
/** A page here got it live while visible and focused. */
|
||||
handled: boolean;
|
||||
};
|
||||
/**
|
||||
* A page reported that it got a notification live while visible and
|
||||
* focused. Node-local; `appUid` is the reporting socket's app, absent for
|
||||
* the desktop.
|
||||
*/
|
||||
'notif.handled': {
|
||||
userId: number;
|
||||
appUid?: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
// ---- Metering ----
|
||||
@@ -662,9 +713,18 @@ export type EventMap = {
|
||||
// and going away (1→0, whether by removing the last handler or by the
|
||||
// destroy route). `actor.user` is the app's owner, not the caller — a
|
||||
// developer session publishing for an app it owns is the common case, but
|
||||
// billing follows ownership.
|
||||
'events.worker.create': { actor: Actor; appUid: string };
|
||||
'events.worker.destroy': { actor: Actor; appUid: string };
|
||||
// billing follows ownership. For `scope: 'user'` the unit is one user's
|
||||
// handlers in one app instead, and `actor.user` is that user.
|
||||
'events.worker.create': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
'events.worker.destroy': {
|
||||
actor: Actor;
|
||||
appUid: string;
|
||||
scope: 'app' | 'user';
|
||||
};
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
@@ -687,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
|
||||
@@ -825,8 +887,17 @@ 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': {
|
||||
sessionUids?: string[];
|
||||
tokenUid?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A grant was withdrawn, so whatever was standing on it has to be settled
|
||||
@@ -919,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;
|
||||
@@ -950,6 +1036,9 @@ export type AiCostFactorEvent = {
|
||||
*/
|
||||
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
|
||||
|
||||
/** Status a vetoing `before` listener may answer with. */
|
||||
export type LifecycleRejectStatus = 403 | 429;
|
||||
|
||||
/**
|
||||
* Payload for `driver.<iface>.<method>.<phase>` events.
|
||||
*
|
||||
@@ -979,6 +1068,8 @@ export type DriverMethodLifecycleEvent = {
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
/** Status for a veto: 403 (default) or 429 for a throttle. */
|
||||
rejectStatus?: LifecycleRejectStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1013,6 +1104,8 @@ export type RouteLifecycleEvent = {
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
/** Status for a veto: 403 (default) or 429 for a throttle. */
|
||||
rejectStatus?: LifecycleRejectStatus;
|
||||
};
|
||||
|
||||
export type EventKey = keyof EventMap & string;
|
||||
@@ -1042,11 +1135,6 @@ export interface NotifDeliveryResponder {
|
||||
visibilityState: 'visible' | 'hidden' | 'unknown';
|
||||
/** The page's window has focus. Absent from older clients. */
|
||||
focused?: boolean;
|
||||
/**
|
||||
* The page shows its own browser notification while hidden or unfocused.
|
||||
* Absent from older clients.
|
||||
*/
|
||||
showsOwn?: boolean;
|
||||
}
|
||||
|
||||
export type EventMetadata = {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,7 @@ import { PuterClient } from '../types.js';
|
||||
* x-puter-internal-auth: <internal secret>
|
||||
* x-puter-events-script: <script the handler set deploys as>
|
||||
* x-puter-events-app: <app uid, for the deploy-on-miss callback>
|
||||
* x-puter-events-holder: <holder uuid of a user scope; absent for app scope>
|
||||
* x-puter-events-key: <key derived for that script>
|
||||
* { handler, event, ctx, token }
|
||||
*
|
||||
@@ -43,6 +44,20 @@ import { PuterClient } from '../types.js';
|
||||
*
|
||||
* On a miss, a handler set here may deploy the script itself and retry once
|
||||
* with `x-puter-events-deployed: 1`, skipping the dispatcher's own callback.
|
||||
*
|
||||
* Calls that come due within a few milliseconds of each other go out together:
|
||||
*
|
||||
* POST <events dispatcher>/invoke-batch
|
||||
* x-puter-internal-auth: <internal secret>
|
||||
* { calls: [{ script, appUid, holder?, key, deployed?, body, waitMs }] }
|
||||
*
|
||||
* The dispatcher runs a few at a time, in order, and streams NDJSON: `{ i,
|
||||
* started: true }` when a call goes to its worker, then `{ i, status, handled?,
|
||||
* error?, retryAfter?, dispatch? }` when it finishes — the same facts the
|
||||
* single route carries in its status and headers. A call's timeout runs from
|
||||
* its `started` line, so time spent queued behind other calls is never the
|
||||
* handler's. A call still queued `waitMs` after the batch arrived is never
|
||||
* started and answers `dispatch: "queue-timeout"`.
|
||||
*/
|
||||
|
||||
/** The one route an events worker answers. */
|
||||
@@ -51,6 +66,27 @@ export const EVENTS_INVOKE_PATH = '/__events/invoke';
|
||||
/** The route the events dispatcher takes an invocation on. */
|
||||
export const EVENTS_DISPATCH_PATH = '/invoke';
|
||||
|
||||
/** The route it takes several on, answering each as it finishes. */
|
||||
export const EVENTS_DISPATCH_BATCH_PATH = '/invoke-batch';
|
||||
|
||||
/** Calls one batch carries at most. */
|
||||
export const INVOKE_BATCH_SIZE = 60;
|
||||
|
||||
/** How long the first call of a batch waits for others to join it. */
|
||||
export const INVOKE_BATCH_WINDOW_MS = 5;
|
||||
|
||||
/**
|
||||
* How long a dispatcher without the batch route is sent single calls before the
|
||||
* batch route is tried again.
|
||||
*/
|
||||
const BATCH_UNSUPPORTED_RETRY_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* How long past its wait a batched call's `started` line may take to arrive
|
||||
* before the call is given up as never started.
|
||||
*/
|
||||
const BATCH_START_GRACE_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Header the dispatcher marks its own failures with. Anything carrying it never
|
||||
* reached a handler, so its status says nothing about the delivery.
|
||||
@@ -92,6 +128,12 @@ const PLATFORM_ERROR_CODES = new Set([
|
||||
*/
|
||||
export const EVENTS_DEPLOYED_HEADER = 'x-puter-events-deployed';
|
||||
|
||||
/**
|
||||
* Names the user whose scope a script belongs to, so a miss deploys it from
|
||||
* their handlers. Absent for an app's own scope.
|
||||
*/
|
||||
export const EVENTS_HOLDER_HEADER = 'x-puter-events-holder';
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons this backend can resolve itself by deploying, rather
|
||||
* than wait on the rehydrate callback finding a backend behind the dispatcher's
|
||||
@@ -112,21 +154,90 @@ const SELF_DEPLOYABLE_MISS_REASONS = new Set([
|
||||
*/
|
||||
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
|
||||
|
||||
/** Whose call asked for the deploy, for the share of deploys it may take. */
|
||||
export interface WorkerMissOptions {
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* Holder uuid of the user scope the script belongs to; absent for app
|
||||
* scope.
|
||||
*/
|
||||
holder?: string;
|
||||
}
|
||||
|
||||
/** What a self-deploy attempt did, and to which app/script it applies. */
|
||||
export type WorkerMissHandler = (
|
||||
appUid: string,
|
||||
script: string,
|
||||
options: WorkerMissOptions,
|
||||
) => Promise<'deployed' | string>;
|
||||
|
||||
/**
|
||||
* `work`'s result, or null once `ms` has passed. The work keeps running, and a
|
||||
* failure after the cutoff is logged since nothing else would see it.
|
||||
*/
|
||||
const withinTime = async <T>(
|
||||
work: Promise<T>,
|
||||
ms: number,
|
||||
): Promise<T | null> => {
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const limit = new Promise<null>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
resolve(null);
|
||||
},
|
||||
Math.max(0, ms),
|
||||
);
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await work;
|
||||
} catch (err) {
|
||||
if (timedOut) console.warn('[events] deploy failed late', err);
|
||||
}
|
||||
})();
|
||||
try {
|
||||
return await Promise.race([work, limit]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
/** How long a handler has to answer before the attempt is abandoned. */
|
||||
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How many invoke timeouts a delivery's worker lease lasts. The lease store
|
||||
* reads this, and `invoke()` keeps a whole call, deploy and resend included,
|
||||
* inside it.
|
||||
*/
|
||||
export const EVENTS_WORKER_LEASE_FACTOR = 2;
|
||||
|
||||
/**
|
||||
* Left unspent at the end of the lease, and the least a resend is worth
|
||||
* starting with: below it the call is given up as `unavailable` and retried.
|
||||
*/
|
||||
export const EVENTS_DEADLINE_MARGIN_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. Also how many calls one transport makes at once: a call past
|
||||
* that waits for a free one before its invoke timeout starts.
|
||||
*/
|
||||
export const INVOKE_CONNECTIONS = 256;
|
||||
|
||||
/**
|
||||
* Of those, how many schedule runs may hold at once, so a burst of schedules
|
||||
* can never take the connections event deliveries need.
|
||||
*/
|
||||
export const SCHEDULE_INVOKE_CONNECTIONS = 64;
|
||||
|
||||
/** Whose call this is, for the connection share it may take. */
|
||||
export type InvokeLane = 'delivery' | 'schedule';
|
||||
|
||||
/** What a deploy answers when another deploy of the region holds the way. */
|
||||
export const DEPLOY_BUSY = 'busy';
|
||||
const INVOKE_KEEP_ALIVE_MS = 30_000;
|
||||
|
||||
/**
|
||||
@@ -141,6 +252,8 @@ export interface WorkerInvokeRequest {
|
||||
/** Script the app's current handler set deploys as. */
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Holder uuid, when the handler lives in a user scope. */
|
||||
holder?: string;
|
||||
handler: string;
|
||||
/** The subscriber's app session token. */
|
||||
token: string;
|
||||
@@ -154,6 +267,8 @@ export interface WorkerInvokeRequest {
|
||||
export interface EventsInvokeCall {
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Holder uuid of the user scope the script belongs to. */
|
||||
holder?: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
/** Serialized `{ handler, event, ctx, token }`. */
|
||||
@@ -161,6 +276,17 @@ export interface EventsInvokeCall {
|
||||
timeoutMs: number;
|
||||
/** Set on the retry after this backend deployed the script itself. */
|
||||
deployed?: boolean;
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* Builds `body` again at send time, for a transport that waits for a
|
||||
* connection first, so a send time stamped into it is the real one.
|
||||
*/
|
||||
render?: () => string;
|
||||
/**
|
||||
* Rechecked by a transport that waits before sending; false drops the call
|
||||
* with {@link supersededAnswer}.
|
||||
*/
|
||||
beforeSend?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** What one attempt at delivering an invocation came back with. */
|
||||
@@ -176,6 +302,8 @@ export interface EventsInvokeAnswer {
|
||||
timedOut?: boolean;
|
||||
/** From a `retry-after` header in seconds. */
|
||||
retryAfterMs?: number;
|
||||
/** A deploy this call needed was put off: another held the way. */
|
||||
deployBusy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,6 +326,8 @@ export type WorkerInvokeOutcome =
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
/** A deploy on miss was put off because another one held the way. */
|
||||
deployBusy?: boolean;
|
||||
/** The status the handler answered with, or `null` when it never did. */
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
@@ -206,9 +336,24 @@ export interface WorkerInvokeResult {
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
/** How one invocation is sent. */
|
||||
export interface WorkerInvokeOptions {
|
||||
lane?: InvokeLane;
|
||||
/** Rechecked before each send, including a resend after deployment. */
|
||||
beforeSend?: () => Promise<boolean>;
|
||||
/** Applied to the event as each attempt is sent. */
|
||||
stampEvent?: (event: unknown) => unknown;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
'no events dispatcher configured (url and internal secret)';
|
||||
|
||||
/** What a call whose `beforeSend` said no answers instead of being sent. */
|
||||
const supersededAnswer = (): EventsInvokeAnswer => ({
|
||||
status: null,
|
||||
error: 'invocation superseded',
|
||||
});
|
||||
|
||||
export class EventsWorkerInvokerClient extends PuterClient {
|
||||
/** Set only where invocations do not leave the process — local development. */
|
||||
#transport: EventsInvokeTransport | null = null;
|
||||
@@ -236,7 +381,10 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
this.#dispatcher = null;
|
||||
}
|
||||
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
async invoke(
|
||||
request: WorkerInvokeRequest,
|
||||
options: WorkerInvokeOptions = {},
|
||||
): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return {
|
||||
@@ -245,32 +393,86 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
error: NO_TRANSPORT,
|
||||
};
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
key: request.key,
|
||||
body: JSON.stringify({
|
||||
const { stampEvent } = options;
|
||||
const render = (): string =>
|
||||
JSON.stringify({
|
||||
handler: request.handler,
|
||||
event: request.event,
|
||||
event: stampEvent ? stampEvent(request.event) : request.event,
|
||||
ctx: request.ctx,
|
||||
token: request.token,
|
||||
}),
|
||||
timeoutMs: this.#timeoutMs(),
|
||||
});
|
||||
const timeoutMs = this.#timeoutMs();
|
||||
// The whole call, deploy and resend included, ends before the lease a
|
||||
// delivery holds would let it be claimed again mid-run.
|
||||
// A short configured timeout gets a proportionally short margin.
|
||||
const margin = Math.min(EVENTS_DEADLINE_MARGIN_MS, timeoutMs / 2);
|
||||
const deadline =
|
||||
Date.now() + timeoutMs * EVENTS_WORKER_LEASE_FACTOR - margin;
|
||||
const send = async (
|
||||
deployed: boolean,
|
||||
sendTimeoutMs = timeoutMs,
|
||||
): Promise<EventsInvokeAnswer> => {
|
||||
if (options.beforeSend && !(await options.beforeSend()))
|
||||
return supersededAnswer();
|
||||
return transport.send({
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
...(request.holder ? { holder: request.holder } : {}),
|
||||
key: request.key,
|
||||
body: render(),
|
||||
timeoutMs: sendTimeoutMs,
|
||||
...(deployed ? { deployed: true } : {}),
|
||||
...(options.lane ? { lane: options.lane } : {}),
|
||||
...(stampEvent ? { render } : {}),
|
||||
...(options.beforeSend
|
||||
? { beforeSend: options.beforeSend }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
let result = await transport.send(call);
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
// A send may wait for a connection for half its timeout before the
|
||||
// run's own timeout starts, so it is given what is left divided by that.
|
||||
const resend = (): Promise<EventsInvokeAnswer> | null => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining < margin) return null;
|
||||
return send(
|
||||
true,
|
||||
Math.floor(
|
||||
Math.min(
|
||||
timeoutMs,
|
||||
remaining / (1 + CONNECTION_WAIT_SHARE),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const outOfTime = (): WorkerInvokeResult => ({
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: 'out of time to resend after deploy',
|
||||
});
|
||||
|
||||
let result = await send(false);
|
||||
if (this.#missHandler && selfDeployable(result)) {
|
||||
// The deploy may be shared with other callers, so it is left
|
||||
// running when this call runs out of time for it.
|
||||
const outcome = await withinTime(
|
||||
this.#missHandler(request.appUid, request.script, {
|
||||
lane: options.lane,
|
||||
...(request.holder ? { holder: request.holder } : {}),
|
||||
}),
|
||||
deadline - Date.now() - margin,
|
||||
);
|
||||
if (outcome === null) return outOfTime();
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === DEPLOY_BUSY ? { deployBusy: true } : {}),
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
} else if (
|
||||
result.status === null &&
|
||||
result.dispatchReason &&
|
||||
@@ -278,10 +480,19 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
) {
|
||||
// No deploy to trigger — just give propagation the one retry
|
||||
// it usually needs, without spending an upload on it.
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
const resent = await resend();
|
||||
if (!resent) return outOfTime();
|
||||
result = resent;
|
||||
}
|
||||
|
||||
const { status, handled, error, timedOut, retryAfterMs } = result;
|
||||
if (result.deployBusy)
|
||||
return {
|
||||
outcome: 'unavailable',
|
||||
status: null,
|
||||
error,
|
||||
deployBusy: true,
|
||||
};
|
||||
// Past the timeout the handler was most likely still running, which
|
||||
// is its own doing; any other missing answer never reached it.
|
||||
if (status === null)
|
||||
@@ -313,9 +524,19 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
// a misconfiguration worth reporting once per attempt rather than a
|
||||
// round trip.
|
||||
if (!dispatcherUrl || !internalSecret) return null;
|
||||
const { invokeBatchSize, invokeBatchWindowMs } =
|
||||
this.config.events ?? {};
|
||||
this.#dispatcher ??= new DispatcherInvokeTransport(
|
||||
dispatcherUrl,
|
||||
internalSecret,
|
||||
{
|
||||
...(invokeBatchSize !== undefined
|
||||
? { batchSize: invokeBatchSize }
|
||||
: {}),
|
||||
...(invokeBatchWindowMs !== undefined
|
||||
? { batchWindowMs: invokeBatchWindowMs }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return this.#dispatcher;
|
||||
}
|
||||
@@ -328,28 +549,65 @@ export class EventsWorkerInvokerClient extends PuterClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** A call waiting in, or sent with, the current batch. */
|
||||
interface BatchedCall {
|
||||
call: EventsInvokeCall;
|
||||
resolve: (answer: EventsInvokeAnswer) => void;
|
||||
/** For a `beforeSend` that threw. */
|
||||
reject: (err: unknown) => void;
|
||||
/** When its wait for a connection began, for what is left of it. */
|
||||
waitingSince: number;
|
||||
}
|
||||
|
||||
export interface DispatcherInvokeTransportOptions {
|
||||
fetchImpl?: typeof undiciFetch;
|
||||
/** Calls per batch; 1 sends every call on its own. */
|
||||
batchSize?: number;
|
||||
batchWindowMs?: number;
|
||||
}
|
||||
|
||||
/** Reaches an events worker the only way production can: through the dispatcher. */
|
||||
export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
readonly #url: string;
|
||||
readonly #secret: string;
|
||||
readonly #fetchImpl: typeof undiciFetch;
|
||||
readonly #batchSize: number;
|
||||
readonly #batchWindowMs: number;
|
||||
#agent: Agent | null = null;
|
||||
/** Calls holding a connection slot. */
|
||||
#active = 0;
|
||||
/** Calls waiting for one, longest-waiting first. */
|
||||
readonly #waiting = new Set<() => void>();
|
||||
/** Of those, schedule runs. */
|
||||
#scheduleActive = 0;
|
||||
/** Calls waiting for one, longest-waiting first, with their lane. */
|
||||
readonly #waiting = new Map<() => void, InvokeLane>();
|
||||
/** Calls gathered for the next batch. */
|
||||
#batch: BatchedCall[] = [];
|
||||
#batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Until when the dispatcher is taken not to have the batch route. */
|
||||
#batchUnsupportedUntil = 0;
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
secret: string,
|
||||
options: { fetchImpl?: typeof undiciFetch } = {},
|
||||
options: DispatcherInvokeTransportOptions = {},
|
||||
) {
|
||||
this.#url = dispatcherUrl;
|
||||
// Joined as a string: `new URL(path, base)` resolves an absolute path
|
||||
// against the base's origin, dropping any path prefix it carries.
|
||||
this.#url = dispatcherUrl.replace(/\/$/, '');
|
||||
this.#secret = secret;
|
||||
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
|
||||
this.#batchSize = Math.max(
|
||||
1,
|
||||
Math.floor(options.batchSize ?? INVOKE_BATCH_SIZE),
|
||||
);
|
||||
this.#batchWindowMs = Math.max(
|
||||
0,
|
||||
options.batchWindowMs ?? INVOKE_BATCH_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#flush();
|
||||
void this.#agent?.close().catch(() => {});
|
||||
this.#agent = null;
|
||||
}
|
||||
@@ -357,29 +615,102 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// No `timedOut`: the handler never got this call, so the wait is not
|
||||
// its failure.
|
||||
if (!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE)))
|
||||
const lane = call.lane ?? 'delivery';
|
||||
const waitingSince = Date.now();
|
||||
if (
|
||||
!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE, lane))
|
||||
)
|
||||
return {
|
||||
status: null,
|
||||
error: 'no free connection to the events dispatcher',
|
||||
};
|
||||
try {
|
||||
// `new URL(path, base)` resolves an absolute path against the
|
||||
// base's origin, dropping any path prefix `base` already carries.
|
||||
const url = `${this.#url.replace(/\/$/, '')}${EVENTS_DISPATCH_PATH}`;
|
||||
const response = await this.#fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
'x-puter-events-script': call.script,
|
||||
'x-puter-events-app': call.appUid,
|
||||
'x-puter-events-key': call.key,
|
||||
...(call.deployed ? { [EVENTS_DEPLOYED_HEADER]: '1' } : {}),
|
||||
},
|
||||
body: call.body,
|
||||
signal: AbortSignal.timeout(call.timeoutMs),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
if (this.#batchSize === 1) {
|
||||
// The wait for a connection may have outlived the call.
|
||||
if (call.beforeSend && !(await call.beforeSend()))
|
||||
return supersededAnswer();
|
||||
return await this.#sendOne(call);
|
||||
}
|
||||
return await new Promise<EventsInvokeAnswer>((resolve, reject) => {
|
||||
this.#batch.push({ call, resolve, reject, waitingSince });
|
||||
if (this.#batch.length >= this.#batchSize) this.#flush();
|
||||
else
|
||||
this.#batchTimer ??= setTimeout(
|
||||
() => this.#flush(),
|
||||
this.#batchWindowMs,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
this.#release(lane);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send what has gathered: alone when it is one call, else as a batch. */
|
||||
#flush(): void {
|
||||
if (this.#batchTimer) clearTimeout(this.#batchTimer);
|
||||
this.#batchTimer = null;
|
||||
const batch = this.#batch;
|
||||
this.#batch = [];
|
||||
if (batch.length === 0) return;
|
||||
if (batch.some(({ call }) => call.beforeSend))
|
||||
void this.#sendStillCurrent(batch);
|
||||
else this.#sendGathered(batch);
|
||||
}
|
||||
|
||||
#sendGathered(batch: BatchedCall[]): void {
|
||||
if (batch.length === 1 || Date.now() < this.#batchUnsupportedUntil) {
|
||||
for (const { call, resolve } of batch)
|
||||
void this.#sendOne(call).then(resolve);
|
||||
return;
|
||||
}
|
||||
void this.#sendBatch(batch);
|
||||
}
|
||||
|
||||
/** Drops calls superseded while they were gathered, then sends the rest. */
|
||||
async #sendStillCurrent(batch: BatchedCall[]): Promise<void> {
|
||||
const checked = await Promise.all(
|
||||
batch.map(async (entry) => {
|
||||
if (!entry.call.beforeSend) return entry;
|
||||
try {
|
||||
if (await entry.call.beforeSend()) return entry;
|
||||
entry.resolve(supersededAnswer());
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
const current = checked.filter(
|
||||
(entry): entry is BatchedCall => entry !== null,
|
||||
);
|
||||
if (current.length > 0) this.#sendGathered(current);
|
||||
}
|
||||
|
||||
/** One call on the single route. */
|
||||
async #sendOne(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
try {
|
||||
const response = await this.#fetchImpl(
|
||||
`${this.#url}${EVENTS_DISPATCH_PATH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
'x-puter-events-script': call.script,
|
||||
'x-puter-events-app': call.appUid,
|
||||
...(call.holder
|
||||
? { [EVENTS_HOLDER_HEADER]: call.holder }
|
||||
: {}),
|
||||
'x-puter-events-key': call.key,
|
||||
...(call.deployed
|
||||
? { [EVENTS_DEPLOYED_HEADER]: '1' }
|
||||
: {}),
|
||||
},
|
||||
body: call.render ? call.render() : call.body,
|
||||
signal: AbortSignal.timeout(call.timeoutMs),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
},
|
||||
);
|
||||
// The body is not read: a handler answers with a status, and
|
||||
// whatever else it writes is its own business.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
@@ -390,24 +721,198 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError !== null)
|
||||
return {
|
||||
status: null,
|
||||
error: `dispatcher: ${dispatchError} (${response.status})`,
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
return dispatchAnswer(dispatchError, response.status);
|
||||
|
||||
return answerFrom(response);
|
||||
} catch (err) {
|
||||
return failedAnswer(err);
|
||||
} finally {
|
||||
this.#release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several calls in one request. A call may wait in the dispatcher's queue
|
||||
* for whatever is left of its connection wait, and its timeout runs from
|
||||
* its `started` line; the request itself is dropped once every call has an
|
||||
* answer.
|
||||
*/
|
||||
async #sendBatch(batch: BatchedCall[]): Promise<void> {
|
||||
const hangUp = new AbortController();
|
||||
let open = batch.length;
|
||||
const answered = batch.map(() => false);
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const finish = (i: number, done: (entry: BatchedCall) => void) => {
|
||||
if (answered[i]) return;
|
||||
answered[i] = true;
|
||||
clearTimeout(timers[i]);
|
||||
done(batch[i]);
|
||||
if (--open === 0) hangUp.abort();
|
||||
};
|
||||
const settle = (i: number, answer: EventsInvokeAnswer): void =>
|
||||
finish(i, (entry) => entry.resolve(answer));
|
||||
const settleRest = (answer: EventsInvokeAnswer): void => {
|
||||
for (let i = 0; i < batch.length; i++) settle(i, answer);
|
||||
};
|
||||
|
||||
const now = Date.now();
|
||||
const waits = batch.map(({ call, waitingSince }) =>
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
call.timeoutMs * CONNECTION_WAIT_SHARE -
|
||||
(now - waitingSince),
|
||||
),
|
||||
),
|
||||
);
|
||||
const body = JSON.stringify({
|
||||
calls: batch.map(({ call }, i) => ({
|
||||
script: call.script,
|
||||
appUid: call.appUid,
|
||||
...(call.holder ? { holder: call.holder } : {}),
|
||||
key: call.key,
|
||||
...(call.deployed ? { deployed: true } : {}),
|
||||
body: call.render ? call.render() : call.body,
|
||||
waitMs: waits[i],
|
||||
})),
|
||||
});
|
||||
const arm = (
|
||||
i: number,
|
||||
ms: number,
|
||||
answer: () => EventsInvokeAnswer,
|
||||
) => {
|
||||
clearTimeout(timers[i]);
|
||||
timers[i] = setTimeout(() => settle(i, answer()), ms);
|
||||
timers[i].unref?.();
|
||||
};
|
||||
// No `timedOut`: a call that never started never reached a handler.
|
||||
batch.forEach((_, i) =>
|
||||
arm(i, waits[i] + BATCH_START_GRACE_MS, () => ({
|
||||
status: null,
|
||||
error: 'dispatcher: call never started',
|
||||
})),
|
||||
);
|
||||
const started = (i: number): void => {
|
||||
if (!answered[i])
|
||||
arm(i, batch[i].call.timeoutMs, () =>
|
||||
failedAnswer(timeoutError()),
|
||||
);
|
||||
};
|
||||
const longest = Math.max(
|
||||
...batch.map(
|
||||
({ call }, i) =>
|
||||
waits[i] + BATCH_START_GRACE_MS + call.timeoutMs,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.#fetchImpl(
|
||||
`${this.#url}${EVENTS_DISPATCH_BATCH_PATH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-internal-auth': this.#secret,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.any([
|
||||
hangUp.signal,
|
||||
AbortSignal.timeout(longest),
|
||||
]),
|
||||
dispatcher: this.#dispatcherFor(),
|
||||
},
|
||||
);
|
||||
|
||||
const dispatchError = response.headers.get(
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError === 'no-such-route') {
|
||||
// A dispatcher from before the batch route: nothing ran, so
|
||||
// every call goes again on its own.
|
||||
void response.body?.cancel().catch(() => {});
|
||||
this.#batchUnsupportedUntil =
|
||||
Date.now() + BATCH_UNSUPPORTED_RETRY_MS;
|
||||
batch.forEach(({ call }, i) => {
|
||||
if (answered[i]) return;
|
||||
const { beforeSend } = call;
|
||||
if (!beforeSend) {
|
||||
void this.#sendOne(call).then((a) => settle(i, a));
|
||||
return;
|
||||
}
|
||||
// The batch round trip may have outlived the call.
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await beforeSend()))
|
||||
return settle(i, supersededAnswer());
|
||||
} catch (err) {
|
||||
return finish(i, (entry) => entry.reject(err));
|
||||
}
|
||||
settle(i, await this.#sendOne(call));
|
||||
})();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (dispatchError !== null || !response.ok) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
// An unmarked failure (an edge 404, a WAF 403) reads as
|
||||
// Puter's, never as any handler's answer.
|
||||
settleRest(
|
||||
dispatchError !== null
|
||||
? dispatchAnswer(dispatchError, response.status)
|
||||
: {
|
||||
status: null,
|
||||
error: `dispatcher: unmarked ${response.status} for a batch`,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let buffered = '';
|
||||
const read = (line: string): void => {
|
||||
if (line.trim() === '') return;
|
||||
const parsed = parseAnswerLine(line);
|
||||
if (!parsed || parsed.i >= batch.length) return;
|
||||
if ('started' in parsed) started(parsed.i);
|
||||
else settle(parsed.i, parsed.answer);
|
||||
};
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of response.body ?? []) {
|
||||
buffered += decoder.decode(chunk, { stream: true });
|
||||
let newline = buffered.indexOf('\n');
|
||||
while (newline !== -1) {
|
||||
read(buffered.slice(0, newline));
|
||||
buffered = buffered.slice(newline + 1);
|
||||
newline = buffered.indexOf('\n');
|
||||
}
|
||||
}
|
||||
read(buffered);
|
||||
settleRest({
|
||||
status: null,
|
||||
error: 'dispatcher: batch ended without an answer',
|
||||
});
|
||||
} catch (err) {
|
||||
// Every call has its answer and the request was dropped on purpose.
|
||||
if (open === 0) return;
|
||||
settleRest(failedAnswer(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a call on `lane` may take a connection now. */
|
||||
#free(lane: InvokeLane): boolean {
|
||||
return (
|
||||
this.#active < INVOKE_CONNECTIONS &&
|
||||
(lane !== 'schedule' ||
|
||||
this.#scheduleActive < SCHEDULE_INVOKE_CONNECTIONS)
|
||||
);
|
||||
}
|
||||
|
||||
#take(lane: InvokeLane): void {
|
||||
this.#active++;
|
||||
if (lane === 'schedule') this.#scheduleActive++;
|
||||
}
|
||||
|
||||
/** A connection slot, or false once `waitMs` passes without one. */
|
||||
#acquire(waitMs: number): Promise<boolean> {
|
||||
if (this.#active < INVOKE_CONNECTIONS) {
|
||||
this.#active++;
|
||||
#acquire(waitMs: number, lane: InvokeLane): Promise<boolean> {
|
||||
if (this.#free(lane)) {
|
||||
this.#take(lane);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
@@ -420,19 +925,24 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
resolve(false);
|
||||
}, waitMs);
|
||||
timer.unref?.();
|
||||
this.#waiting.add(granted);
|
||||
this.#waiting.set(granted, lane);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand the slot to the longest waiter, or give it back. */
|
||||
#release(): void {
|
||||
const next = this.#waiting.values().next().value;
|
||||
if (next) {
|
||||
this.#waiting.delete(next);
|
||||
next();
|
||||
/**
|
||||
* Give the slot back, then hand one to the longest waiter it can go to: a
|
||||
* schedule waiter at a full schedule share doesn't hold up a delivery.
|
||||
*/
|
||||
#release(lane: InvokeLane): void {
|
||||
this.#active--;
|
||||
if (lane === 'schedule') this.#scheduleActive--;
|
||||
for (const [granted, waiting] of this.#waiting) {
|
||||
if (!this.#free(waiting)) continue;
|
||||
this.#waiting.delete(granted);
|
||||
this.#take(waiting);
|
||||
granted();
|
||||
return;
|
||||
}
|
||||
this.#active--;
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
@@ -446,6 +956,65 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a failure the dispatcher marked as its own reads as. */
|
||||
const dispatchAnswer = (
|
||||
reason: string,
|
||||
status: number,
|
||||
): EventsInvokeAnswer => ({
|
||||
status: null,
|
||||
error: `dispatcher: ${reason} (${status})`,
|
||||
dispatchReason: reason,
|
||||
});
|
||||
|
||||
/** What `AbortSignal.timeout` rejects with, for a batched call's own timeout. */
|
||||
const timeoutError = (): Error =>
|
||||
new DOMException(
|
||||
'The operation was aborted due to timeout',
|
||||
'TimeoutError',
|
||||
);
|
||||
|
||||
/** One line of a batch answer, or null when it is not one. */
|
||||
export const parseAnswerLine = (
|
||||
line: string,
|
||||
):
|
||||
| { i: number; started: true }
|
||||
| { i: number; answer: EventsInvokeAnswer }
|
||||
| null => {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const { i, started, status, handled, error, retryAfter, dispatch } =
|
||||
raw as Record<string, unknown>;
|
||||
if (!Number.isInteger(i) || (i as number) < 0) return null;
|
||||
if (started === true) return { i: i as number, started: true };
|
||||
if (typeof status !== 'number') return null;
|
||||
if (typeof dispatch === 'string')
|
||||
return { i: i as number, answer: dispatchAnswer(dispatch, status) };
|
||||
return {
|
||||
i: i as number,
|
||||
answer: answerFrom({
|
||||
status,
|
||||
headers: {
|
||||
get: (name) => {
|
||||
if (name === EVENTS_HANDLED_HEADER)
|
||||
return handled === true ? '1' : null;
|
||||
if (name === EVENTS_ERROR_HEADER)
|
||||
return typeof error === 'string' ? error : null;
|
||||
if (name === 'retry-after')
|
||||
return typeof retryAfter === 'string'
|
||||
? retryAfter
|
||||
: null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/** Whether deploying the script is what fixes this answer. */
|
||||
const selfDeployable = (result: EventsInvokeAnswer): boolean =>
|
||||
result.status === null
|
||||
|
||||
@@ -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',
|
||||
@@ -5132,6 +5174,26 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
expect(staged).toBe('+14155550123');
|
||||
});
|
||||
|
||||
it('makes an email-gate-last account verify its card before its phone', async () => {
|
||||
const { user, actor } = await makeUserAndActor({
|
||||
requires_phone_verification: 1,
|
||||
requires_card_verification: 1,
|
||||
});
|
||||
await server.stores.user.updateMetadata(user.id, {
|
||||
emailGateLast: true,
|
||||
});
|
||||
const prelude = stubPrelude();
|
||||
await withClients({ prelude }, async () => {
|
||||
await expect(
|
||||
controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
expect(prelude.createVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const emitAndWait = vi.fn(
|
||||
@@ -5197,7 +5259,7 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the client dispatch id on the sent signal', async () => {
|
||||
it('carries the client dispatch id on the check and sent events', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
const emit = vi.fn();
|
||||
const emitAndWait = vi.fn(async () => {});
|
||||
@@ -5218,6 +5280,11 @@ describe('AuthController phone verification — staging & reuse', () => {
|
||||
expect.objectContaining({ dispatch_id: 'disp-123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(emitAndWait).toHaveBeenCalledWith(
|
||||
'puter.phone-verification.check',
|
||||
expect.objectContaining({ dispatch_id: 'disp-123' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT emit the sent signal when the send fails upstream', async () => {
|
||||
@@ -7443,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,
|
||||
@@ -7450,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,
|
||||
@@ -7483,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(
|
||||
@@ -8258,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,
|
||||
{},
|
||||
@@ -8275,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 () => {
|
||||
@@ -9475,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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9503,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(
|
||||
@@ -10047,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
|
||||
@@ -10527,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
|
||||
@@ -10606,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();
|
||||
@@ -10624,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 -----------------------
|
||||
@@ -10988,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -66,10 +66,12 @@ import {
|
||||
isCardFallbackEnabled,
|
||||
phoneAttemptsKey,
|
||||
} from '../../util/cardFallback.js';
|
||||
import { isEmailGateLast } from '../../util/verificationOrder.js';
|
||||
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,
|
||||
@@ -142,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
|
||||
@@ -344,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',
|
||||
});
|
||||
@@ -385,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', {
|
||||
@@ -417,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}`,
|
||||
{
|
||||
@@ -881,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1027,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', {
|
||||
@@ -1220,6 +1286,18 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
/** The email-gate-last confirmation email; a failure never fails the gate. */
|
||||
private async sendDeferredConfirmation(userId: number): Promise<void> {
|
||||
try {
|
||||
await this.services.signup.sendDeferredConfirmationEmail(userId);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[verification] deferred confirmation email failed:',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async isCardFallbackEligible(user: {
|
||||
id: number;
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
@@ -1265,6 +1343,12 @@ export class AuthController extends PuterController {
|
||||
throw new HttpError(403, 'Account suspended.', {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
if (isEmailGateLast(user) && user.requires_card_verification)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Card verification must be completed first.',
|
||||
{ legacyCode: 'conflict' },
|
||||
);
|
||||
if (!this.clients.prelude?.isConfigured())
|
||||
throw await this.smsSendError(
|
||||
503,
|
||||
@@ -1335,6 +1419,7 @@ export class AuthController extends PuterController {
|
||||
user_uid: user.uuid,
|
||||
phone: parsed.e164,
|
||||
device_fingerprint: req.deviceFingerprint ?? null,
|
||||
dispatch_id: dispatchId ?? null,
|
||||
allowed: true,
|
||||
reason: null as string | null,
|
||||
card_fallback_instead: false,
|
||||
@@ -1425,6 +1510,7 @@ export class AuthController extends PuterController {
|
||||
ip,
|
||||
device_id: req.deviceFingerprint ?? undefined,
|
||||
user_agent: userAgent,
|
||||
tls_fingerprint: req.tlsFingerprint,
|
||||
dispatch_id: dispatchId,
|
||||
},
|
||||
);
|
||||
@@ -1573,6 +1659,7 @@ export class AuthController extends PuterController {
|
||||
requires_phone_verification: 0,
|
||||
phone: pendingPhone,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
|
||||
// Run the verified-event listeners synchronously (emitAndWait, not
|
||||
// fire-and-forget emit) so a carrier-based card-verification waiver in
|
||||
@@ -1653,7 +1740,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
if (
|
||||
user.requires_phone_verification &&
|
||||
!fallbackEligible &&
|
||||
!isEmailGateLast(user)
|
||||
)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1716,6 +1807,7 @@ export class AuthController extends PuterController {
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
res.json({ card_verified: true, disabled: true });
|
||||
return;
|
||||
}
|
||||
@@ -1775,7 +1867,11 @@ export class AuthController extends PuterController {
|
||||
res.json({ card_verified: true });
|
||||
return;
|
||||
}
|
||||
if (user.requires_phone_verification && !fallbackEligible)
|
||||
if (
|
||||
user.requires_phone_verification &&
|
||||
!fallbackEligible &&
|
||||
!isEmailGateLast(user)
|
||||
)
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Phone verification must be completed first.',
|
||||
@@ -1816,6 +1912,7 @@ export class AuthController extends PuterController {
|
||||
await this.stores.user.update(user.id, {
|
||||
requires_card_verification: 0,
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
res.json({ card_verified: true, disabled: true });
|
||||
return;
|
||||
}
|
||||
@@ -1840,6 +1937,7 @@ export class AuthController extends PuterController {
|
||||
requires_card_verification: 0,
|
||||
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
||||
});
|
||||
await this.sendDeferredConfirmation(user.id);
|
||||
|
||||
// Awaited like `user.phone-verified`, so whatever a listener grants on
|
||||
// verification is in place before the client refreshes. emitAndWait
|
||||
@@ -1987,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);
|
||||
@@ -2250,6 +2349,7 @@ export class AuthController extends PuterController {
|
||||
{
|
||||
username: user.username,
|
||||
},
|
||||
{ stream: 'auth' },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[change-password] notification send failed:', e);
|
||||
@@ -2475,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);
|
||||
@@ -2482,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.`,
|
||||
@@ -2615,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,
|
||||
{},
|
||||
@@ -2778,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(
|
||||
@@ -3552,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 });
|
||||
@@ -4242,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);
|
||||
}
|
||||
@@ -4318,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);
|
||||
}
|
||||
@@ -4396,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
|
||||
@@ -4420,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,
|
||||
@@ -4736,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
|
||||
@@ -4770,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);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ export interface OidcLoginContinuation {
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
@@ -56,7 +55,6 @@ export function completeOidcLoginReturn(
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
@@ -91,6 +89,5 @@ export function completeOidcLoginReturn(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Post } from '../../core/http/decorators.js';
|
||||
import { schedulesUnavailable } from '../../core/schedules.js';
|
||||
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
|
||||
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -108,6 +109,42 @@ export class BroadcastController extends PuterController {
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule changes a region made for schedules this one owns, handed to
|
||||
* `services.schedules`; `events_disabled` on a server without it.
|
||||
*/
|
||||
@Post('/events/schedules', { subdomain: '*' })
|
||||
async schedules(req: Request, res: Response): Promise<void> {
|
||||
const broadcast = this.services.broadcast as unknown as
|
||||
BroadcastService | undefined;
|
||||
if (!broadcast) {
|
||||
res.status(503).json({
|
||||
error: { message: 'Broadcast service not registered' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await broadcast.verifySignedRequest(
|
||||
req.rawBody,
|
||||
broadcastHeaders(req),
|
||||
);
|
||||
if (!verified.ok) {
|
||||
res.status(verified.status ?? 400).json({
|
||||
error: { message: verified.message ?? 'Bad request' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verified.info?.ignored) {
|
||||
res.status(200).json({ ok: true, ...verified.info });
|
||||
return;
|
||||
}
|
||||
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
const reply = await schedules.receivePeerChanges(req.body);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastHeaders = (req: Request) => {
|
||||
|
||||
@@ -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');
|
||||
@@ -736,6 +759,42 @@ describe('DriverController driver-method lifecycle events', () => {
|
||||
rejectReason: 'blocked in test',
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 429 when a before listener vetoes with rejectStatus 429', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await grantKvPerm(actor);
|
||||
const key = `throttle-${uuidv4()}`;
|
||||
server.clients.event.on(
|
||||
'driver.puter-kvstore.set.before',
|
||||
(_k, data) => {
|
||||
if ((data.args as { key?: string })?.key === key) {
|
||||
data.allow = false;
|
||||
data.rejectStatus = 429;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleListInterfaces', () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
checkDriverRateLimit,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { LifecycleRejectStatus } from '../../clients/event/types.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import {
|
||||
isCreditExhaustion,
|
||||
@@ -275,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`', {
|
||||
@@ -334,7 +337,8 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
PermissionService | undefined;
|
||||
| PermissionService
|
||||
| undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
@@ -475,6 +479,7 @@ export class DriverController extends PuterController {
|
||||
args,
|
||||
allow: true as boolean,
|
||||
rejectReason: undefined as string | undefined,
|
||||
rejectStatus: undefined as LifecycleRejectStatus | undefined,
|
||||
};
|
||||
await this.clients.event?.emitAndWait(
|
||||
`driver.${ifaceName}.${method}.before`,
|
||||
@@ -496,11 +501,12 @@ export class DriverController extends PuterController {
|
||||
},
|
||||
{},
|
||||
);
|
||||
const throttled = beforeEvent.rejectStatus === 429;
|
||||
throw new HttpError(
|
||||
403,
|
||||
throttled ? 429 : 403,
|
||||
beforeEvent.rejectReason ??
|
||||
`Blocked by policy: ${ifaceName}:${method}`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
{ legacyCode: throttled ? 'too_many_requests' : 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
|
||||
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
|
||||
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
|
||||
import {
|
||||
SCHEDULE_LIST_LIMIT_CAP,
|
||||
schedulesUnavailable,
|
||||
type ScheduleServiceContract,
|
||||
} from '../../core/schedules.js';
|
||||
import { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
|
||||
@@ -59,6 +64,8 @@ const EVENTS_SCRIPT_REGEX = new RegExp(`^${EVENTS_WORKER_PREFIX}[a-f0-9]{32}$`);
|
||||
|
||||
/** Matches the edge dispatcher's own validation of the app uid it forwards. */
|
||||
const APP_UID_REGEX = /^app-[A-Za-z0-9_-]{1,64}$/;
|
||||
/** A user uuid. */
|
||||
const HOLDER_REGEX = /^[A-Za-z0-9-]{1,64}$/;
|
||||
|
||||
@Controller('/events')
|
||||
export class EventsController extends PuterController {
|
||||
@@ -127,7 +134,8 @@ export class EventsController extends PuterController {
|
||||
*
|
||||
* Stateless: no subscription, no stored position, nothing written. The
|
||||
* cursor comes back to the caller and goes out again as `cursor` (or
|
||||
* `after`, the name it first shipped under).
|
||||
* `after`, the name it first shipped under). `from=end` with no cursor
|
||||
* starts at the newest row instead of the oldest.
|
||||
*/
|
||||
@Get('/fetch', {
|
||||
subdomain: 'api',
|
||||
@@ -138,9 +146,16 @@ export class EventsController extends PuterController {
|
||||
async fetch(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
const from = query.from as 'start' | 'end' | undefined;
|
||||
if (from !== undefined && from !== 'start' && from !== 'end') {
|
||||
throw new HttpError(400, '`from` must be `start` or `end`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const page = await this.services.events.fetchMissed(actor, {
|
||||
subject: typeof query.subject === 'string' ? query.subject : '',
|
||||
from,
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
after: typeof query.after === 'string' ? query.after : undefined,
|
||||
limit: normalizeLimit(query.limit, {
|
||||
@@ -169,6 +184,99 @@ export class EventsController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// -- Schedules ---------------------------------------------------
|
||||
//
|
||||
// An app's handler run on a timer, served by `services.schedules` when a
|
||||
// server has it and answered `events_disabled` when it doesn't. Creating
|
||||
// one needs an app context; listing, reading and removing are scoped the
|
||||
// way the durable verbs are. The create and remove verbs spend the
|
||||
// subscribe and unsubscribe budgets in the service, shared with those
|
||||
// verbs.
|
||||
|
||||
#schedules(): ScheduleServiceContract {
|
||||
const schedules = this.services.schedules;
|
||||
if (!schedules) throw schedulesUnavailable();
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/** POST /events/schedules — create a schedule for the calling app. */
|
||||
@Post('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async createSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().createFor(actor, bodyRecord(req)));
|
||||
}
|
||||
|
||||
/** GET /events/schedules — the caller's schedules, one page at a time. */
|
||||
@Get('/schedules', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listSchedules(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.#schedules().list(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: SCHEDULE_LIST_LIMIT_CAP,
|
||||
}),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
includeTotal: query.includeTotal === 'true',
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(page.total !== undefined ? { total: page.total } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/schedules/update', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async updateSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const { scheduleId, changes } = bodyRecord(req);
|
||||
res.json(await this.#schedules().update(actor, scheduleId, changes));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/schedules/remove — end one schedule. An id the caller does
|
||||
* not hold reads as absent.
|
||||
*/
|
||||
@Post('/schedules/remove', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async removeSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
await this.#schedules().remove(actor, bodyRecord(req).scheduleId);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/schedules/:scheduleId — one schedule, ended or not. An id the
|
||||
* caller does not hold reads as absent.
|
||||
*/
|
||||
@Get('/schedules/:scheduleId', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async getSchedule(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(await this.#schedules().get(actor, req.params.scheduleId));
|
||||
}
|
||||
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
@@ -315,6 +423,7 @@ export class EventsController extends PuterController {
|
||||
handlers: await this.services.events.listHandlers(actor, {
|
||||
appUid:
|
||||
typeof query.appUid === 'string' ? query.appUid : undefined,
|
||||
scope: query.scope,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -413,6 +522,15 @@ export class EventsController extends PuterController {
|
||||
throw new HttpError(400, 'Missing or invalid `script`/`appUid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
// Absent or empty is the app's own scope.
|
||||
const holder = body.holder === undefined ? '' : body.holder;
|
||||
if (
|
||||
typeof holder !== 'string' ||
|
||||
!(holder === '' || HOLDER_REGEX.test(holder))
|
||||
)
|
||||
throw new HttpError(400, 'Invalid `holder`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) {
|
||||
@@ -420,15 +538,25 @@ export class EventsController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(appUid, script);
|
||||
const scope = await deployer.scopeFor(appUid, holder);
|
||||
if (!scope) {
|
||||
res.status(404).json({ deployed: false, reason: 'no-holder' });
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(scope, script);
|
||||
if (outcome === 'deployed') {
|
||||
res.json({ deployed: true });
|
||||
return;
|
||||
}
|
||||
// `failed` and `throttled` are worth another attempt: the rest say
|
||||
// this script is not what the app's handlers currently are.
|
||||
// `failed`, `throttled` and `busy` are worth another attempt: the rest
|
||||
// say this script is not what the app's handlers currently are.
|
||||
res.status(
|
||||
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
|
||||
outcome === 'failed' ||
|
||||
outcome === 'throttled' ||
|
||||
outcome === 'busy'
|
||||
? 502
|
||||
: 404,
|
||||
).json({
|
||||
deployed: false,
|
||||
reason: outcome,
|
||||
@@ -449,14 +577,22 @@ export class EventsController extends PuterController {
|
||||
override onServerStart(): void {
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) return;
|
||||
this.services.events.useEagerDeploy((scope, script) =>
|
||||
deployer.deployEagerly(scope, script),
|
||||
);
|
||||
if (this.config.workers?.localServer) {
|
||||
this.services.events.useWorkerTransport(
|
||||
new LocalEventsInvokeTransport(this.services, deployer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.services.events.useWorkerMissHandler((appUid, script) =>
|
||||
deployer.ensure(appUid, script),
|
||||
this.services.events.useWorkerMissHandler(
|
||||
async (appUid, script, options) => {
|
||||
const scope = await deployer.scopeFor(appUid, options.holder);
|
||||
return scope
|
||||
? deployer.ensure(scope, script, options)
|
||||
: 'no-holder';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,23 +34,46 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_HANDLER_PUBLISH_LIMIT,
|
||||
EVENTS_HANDLER_SOURCE_MAX_BYTES,
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_USER_HANDLERS_PER_SCOPE,
|
||||
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
RENTED_UNITS_PER_USER,
|
||||
limitFor,
|
||||
tiered,
|
||||
type TieredLimit,
|
||||
} from './limits.js';
|
||||
|
||||
const tiers: Array<[string, TieredLimit]> = [
|
||||
['durable subscriptions per account', EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER],
|
||||
[
|
||||
'durable subscriptions per account',
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
],
|
||||
['durable subscriptions per app', EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP],
|
||||
['live share handles per account', EVENTS_KV_HANDLES_PER_USER],
|
||||
['live share handles per app', EVENTS_KV_HANDLES_PER_APP],
|
||||
];
|
||||
|
||||
describe('tiered', () => {
|
||||
it('gives paid plans the base, and the free and temporary tiers their own', () => {
|
||||
const limit = tiered(30, 20, 10);
|
||||
expect(limitFor(limit, null)).toBe(30);
|
||||
expect(limitFor(limit, 'some_plan')).toBe(30);
|
||||
expect(limitFor(limit, DEFAULT_FREE_SUBSCRIPTION)).toBe(20);
|
||||
expect(limitFor(limit, DEFAULT_TEMP_SUBSCRIPTION)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tiered subscription quotas', () => {
|
||||
it.each(tiers)('%s never lets a free tier exceed paid', (_name, tier) => {
|
||||
for (const n of Object.values(tier.bySubscription))
|
||||
@@ -105,9 +128,9 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
it('holds an unrecognised plan to the paid base', () => {
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan')).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
expect(
|
||||
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan'),
|
||||
).toBe(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit);
|
||||
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, null)).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
@@ -131,9 +154,9 @@ describe('the delivery budgets', () => {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan)).toBe(
|
||||
evaluated,
|
||||
);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
@@ -169,3 +192,42 @@ describe('the delivery budgets', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handler limits', () => {
|
||||
it('pins the app-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_HANDLERS_PER_APP).toBe(100);
|
||||
expect(EVENTS_HANDLER_SOURCE_MAX_BYTES).toBe(64 * 1024);
|
||||
expect(EVENTS_WORKER_SOURCE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(EVENTS_WORKER_DEPLOYS_PER_HOUR).toBe(30);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.limit).toBe(60);
|
||||
expect(EVENTS_HANDLER_PUBLISH_LIMIT.window).toBe(60_000);
|
||||
});
|
||||
|
||||
it('pins the user-scope numbers the quotas page lists', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBe(20);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBe(1024 * 1024);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps a user scope tighter than an app scope', () => {
|
||||
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBeLessThan(
|
||||
EVENTS_HANDLERS_PER_APP,
|
||||
);
|
||||
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBeLessThan(
|
||||
EVENTS_WORKER_SOURCE_MAX_BYTES,
|
||||
);
|
||||
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBeLessThan(
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the rented-unit cap by plan', () => {
|
||||
expect(RENTED_UNITS_PER_USER.limit).toBe(100);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_FREE_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_TEMP_SUBSCRIPTION)).toBe(
|
||||
16,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,12 @@ export interface TieredLimit {
|
||||
bySubscription: Record<string, number>;
|
||||
}
|
||||
|
||||
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
/** Paid, free and temporary caps as a {@link TieredLimit}. Called by extensions. */
|
||||
export const tiered = (
|
||||
paid: number,
|
||||
free: number,
|
||||
temp: number,
|
||||
): TieredLimit => ({
|
||||
limit: paid,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: free,
|
||||
@@ -264,6 +269,12 @@ export const KV_SHARE_NOTIFY_LIMITS = [
|
||||
*/
|
||||
export const EVENTS_HANDLERS_PER_APP = 100;
|
||||
|
||||
/**
|
||||
* Handlers one user may hold in their own scope of one app. Each is a script of
|
||||
* its own, so the cap is tighter than the app's.
|
||||
*/
|
||||
export const EVENTS_USER_HANDLERS_PER_SCOPE = 20;
|
||||
|
||||
/** Longest a serialized handler may be. */
|
||||
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -275,6 +286,9 @@ export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Source one user scope may hold, all its handlers combined. */
|
||||
export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deploys one app's events worker may trigger per hour.
|
||||
*
|
||||
@@ -284,6 +298,16 @@ export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
*/
|
||||
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
|
||||
|
||||
/** Deploys one user scope may trigger per hour. */
|
||||
export const EVENTS_USER_WORKER_DEPLOYS_PER_HOUR = 10;
|
||||
|
||||
/**
|
||||
* Units one user may rent at once: ordinary workers, user scopes, and the
|
||||
* events workers of apps they own. Only creating a unit is refused; holdings
|
||||
* over the cap are kept.
|
||||
*/
|
||||
export const RENTED_UNITS_PER_USER = tiered(100, 16, 16);
|
||||
|
||||
/**
|
||||
* Handlers one `publishAll` may carry. A build step publishes its whole set in
|
||||
* one call, and the set is capped by what an app may hold anyway.
|
||||
@@ -448,11 +472,13 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
|
||||
// finish backs off the same way but is not the handler's failure, so it never
|
||||
// counts toward that run.
|
||||
|
||||
/** Wait before the first retry of a delivery a handler could not take. */
|
||||
export const EVENTS_RETRY_BASE_MS = 2_000;
|
||||
|
||||
/** Longest wait between retries, however many have failed. */
|
||||
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
// Wait before the first retry of a delivery a handler could not take, the
|
||||
// longest wait, and the doubling between them. Shared with system jobs.
|
||||
export {
|
||||
deliveryBackoffMs,
|
||||
EVENTS_RETRY_BASE_MS,
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
} from '../../util/retryBackoff.js';
|
||||
|
||||
/**
|
||||
* Handler failures in a row before a subscription is suspended. Counted per
|
||||
@@ -468,13 +494,6 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5;
|
||||
*/
|
||||
export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** How long to hold a delivery whose handler has failed `attempts` times. */
|
||||
export const deliveryBackoffMs = (attempts: number): number =>
|
||||
Math.min(
|
||||
EVENTS_RETRY_MAX_MS,
|
||||
EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
|
||||
);
|
||||
|
||||
// -- Suspended backlog -----------------------------------------------
|
||||
//
|
||||
// A suspended subscription stops metering but keeps what it is owed, and that
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The schedule routes are thin delegates: without `services.schedules` every
|
||||
* one answers 503 `events_disabled`; with it, each hands its request over and
|
||||
* answers what it returns.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
ScheduleServiceContract,
|
||||
ScheduleView,
|
||||
} from '../../core/schedules.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import type { BroadcastController } from '../broadcast/BroadcastController.js';
|
||||
import type { EventsController } from './EventsController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let events: EventsController;
|
||||
let broadcast: BroadcastController;
|
||||
let actor: Actor;
|
||||
let saved: unknown;
|
||||
|
||||
const SCHEDULE_ID = '0b3a3c9e-9f61-4f0e-8d2a-2f1b6f8f2c11';
|
||||
|
||||
const view = {
|
||||
scheduleId: SCHEDULE_ID,
|
||||
kind: 'recurring',
|
||||
appUid: 'app-x',
|
||||
} as ScheduleView;
|
||||
|
||||
const slot = () => server.services as unknown as { schedules?: unknown };
|
||||
/** Put `schedules` in the slot, or leave the slot empty. */
|
||||
const setSchedules = (schedules: unknown): void => {
|
||||
if (schedules === undefined) delete slot().schedules;
|
||||
else slot().schedules = schedules;
|
||||
};
|
||||
|
||||
const fakeSchedules = () =>
|
||||
({
|
||||
onSubscriptionsChanged: vi.fn(async () => {}),
|
||||
createFor: vi.fn(async () => view),
|
||||
list: vi.fn(async () => ({
|
||||
items: [view],
|
||||
cursor: 'next',
|
||||
total: 3,
|
||||
})),
|
||||
get: vi.fn(async () => view),
|
||||
update: vi.fn(async () => view),
|
||||
remove: vi.fn(async () => {}),
|
||||
receivePeerChanges: vi.fn(async () => ({ applied: 2 })),
|
||||
prepareSendAt: vi.fn(),
|
||||
createDeferredAppMessage: vi.fn(),
|
||||
}) satisfies ScheduleServiceContract;
|
||||
|
||||
const req = (init: {
|
||||
body?: unknown;
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, string>;
|
||||
}): Request =>
|
||||
({
|
||||
actor,
|
||||
body: init.body ?? {},
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: {},
|
||||
rawBody: Buffer.from(JSON.stringify(init.body ?? {})),
|
||||
}) as unknown as Request;
|
||||
|
||||
const res = () => {
|
||||
const sent: { status: number; body: unknown } = {
|
||||
status: 200,
|
||||
body: undefined,
|
||||
};
|
||||
const response = {
|
||||
json: vi.fn((body: unknown) => {
|
||||
sent.body = body;
|
||||
return response;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
sent.status = code;
|
||||
return response;
|
||||
}),
|
||||
setHeader: vi.fn(() => response),
|
||||
};
|
||||
return { response: response as unknown as Response, sent };
|
||||
};
|
||||
|
||||
/** Each route, called as the router would. */
|
||||
const routes: Array<[string, (response: Response) => Promise<void>]> = [
|
||||
[
|
||||
'POST /events/schedules',
|
||||
(r) => events.createSchedule(req({ body: { every: 60_000 } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules',
|
||||
(r) => events.listSchedules(req({ query: { limit: '5' } }), r),
|
||||
],
|
||||
[
|
||||
'GET /events/schedules/:scheduleId',
|
||||
(r) =>
|
||||
events.getSchedule(req({ params: { scheduleId: SCHEDULE_ID } }), r),
|
||||
],
|
||||
[
|
||||
'POST /events/schedules/update',
|
||||
(r) => events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes: { every: '2h' } } }), r),
|
||||
],
|
||||
[
|
||||
'POST /events/schedules/remove',
|
||||
(r) =>
|
||||
events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
[
|
||||
'POST /broadcast/events/schedules',
|
||||
(r) =>
|
||||
broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
r,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
events = server.controllers.events as unknown as EventsController;
|
||||
broadcast = server.controllers.broadcast as unknown as BroadcastController;
|
||||
actor = makeActor({
|
||||
user: { id: 1, uuid: 'u-1', username: 'someone' },
|
||||
} as never);
|
||||
saved = slot().schedules;
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValue({ ok: true, peerId: 'peer' } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setSchedules(saved);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('without services.schedules', () => {
|
||||
it.each(routes)('%s answers 503 events_disabled', async (_name, call) => {
|
||||
setSchedules(undefined);
|
||||
const { response } = res();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await call(response);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(isHttpError(thrown)).toBe(true);
|
||||
expect(thrown).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'events_disabled',
|
||||
// The designed answer, so it never pages.
|
||||
noAlarm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an unsigned peer batch before saying schedules are off', async () => {
|
||||
setSchedules(undefined);
|
||||
vi.spyOn(
|
||||
server.services.broadcast,
|
||||
'verifySignedRequest',
|
||||
).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
message: 'bad signature',
|
||||
} as never);
|
||||
const { response, sent } = res();
|
||||
await broadcast.schedules(
|
||||
req({ body: { namespace: 'n', changes: [] } }),
|
||||
response,
|
||||
);
|
||||
expect(sent.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with services.schedules', () => {
|
||||
it('creates through createFor and answers its view', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.createSchedule(req({ body: { every: 60_000 } }), response);
|
||||
expect(schedules.createFor).toHaveBeenCalledWith(actor, {
|
||||
every: 60_000,
|
||||
});
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('lists with the limit capped, the cursor and includeTotal', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.listSchedules(
|
||||
req({
|
||||
query: { limit: '5000', cursor: 'c', includeTotal: 'true' },
|
||||
}),
|
||||
response,
|
||||
);
|
||||
expect(schedules.list).toHaveBeenCalledWith(actor, {
|
||||
limit: 200,
|
||||
cursor: 'c',
|
||||
includeTotal: true,
|
||||
});
|
||||
expect(sent.body).toEqual({ items: [view], cursor: 'next', total: 3 });
|
||||
});
|
||||
|
||||
it('reads one through get', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.getSchedule(
|
||||
req({ params: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.get).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('updates through update and answers its view', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
const changes = { every: '2h' };
|
||||
await events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes } }), response);
|
||||
expect(schedules.update).toHaveBeenCalledWith(actor, SCHEDULE_ID, changes);
|
||||
expect(sent.body).toEqual(view);
|
||||
});
|
||||
|
||||
it('removes through remove and answers {}', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
await events.removeSchedule(
|
||||
req({ body: { scheduleId: SCHEDULE_ID } }),
|
||||
response,
|
||||
);
|
||||
expect(schedules.remove).toHaveBeenCalledWith(actor, SCHEDULE_ID);
|
||||
expect(sent.body).toEqual({});
|
||||
});
|
||||
|
||||
it('hands a signed peer batch to receivePeerChanges', async () => {
|
||||
const schedules = fakeSchedules();
|
||||
setSchedules(schedules);
|
||||
const { response, sent } = res();
|
||||
const body = { namespace: 'n', changes: [] };
|
||||
await broadcast.schedules(req({ body }), response);
|
||||
expect(schedules.receivePeerChanges).toHaveBeenCalledWith(body);
|
||||
expect(sent).toEqual({ status: 200, body: { ok: true, applied: 2 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type {
|
||||
ScheduleServiceContract,
|
||||
ScheduleView,
|
||||
} from '../../core/schedules.js';
|
||||
import { EventsController } from './EventsController.js';
|
||||
|
||||
const actor = makeActor({
|
||||
user: { id: 1, uuid: 'user-1', username: 'user1' } as never,
|
||||
});
|
||||
const request = (body: unknown) => ({ actor, body }) as Request;
|
||||
const controller = (schedules?: Pick<ScheduleServiceContract, 'update'>) =>
|
||||
new EventsController(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ schedules } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
describe('update schedule route contract', () => {
|
||||
it('answers events_disabled when schedules are unavailable', async () => {
|
||||
await expect(
|
||||
controller().updateSchedule(
|
||||
request({ scheduleId: 'id', changes: {} }),
|
||||
{} as Response,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
legacyCode: 'events_disabled',
|
||||
statusCode: 503,
|
||||
noAlarm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the actor, id and changes through and returns the updated view', async () => {
|
||||
const view = {
|
||||
scheduleId: 'id',
|
||||
kind: 'recurring',
|
||||
state: 'suspended',
|
||||
} as ScheduleView;
|
||||
const changes = { cron: '0 9 * * *', tz: 'UTC' };
|
||||
const calls: unknown[][] = [];
|
||||
const events = controller({
|
||||
update: async (...args) => {
|
||||
calls.push(args);
|
||||
return view;
|
||||
},
|
||||
});
|
||||
let sent: unknown;
|
||||
await events.updateSchedule(request({ scheduleId: 'id', changes }), {
|
||||
json: (value: unknown) => {
|
||||
sent = value;
|
||||
},
|
||||
} as Response);
|
||||
expect(calls).toEqual([[actor, 'id', changes]]);
|
||||
expect(sent).toBe(view);
|
||||
});
|
||||
|
||||
it('preserves validation and sending errors from the implementation', async () => {
|
||||
const error = {
|
||||
code: 'events_schedule_sending',
|
||||
message: 'Already sending',
|
||||
};
|
||||
await expect(
|
||||
controller({
|
||||
update: async () => {
|
||||
throw error;
|
||||
},
|
||||
}).updateSchedule(
|
||||
request({ scheduleId: 'id', changes: {} }),
|
||||
{} as Response,
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,35 @@
|
||||
|
||||
import MockRedis from 'ioredis-mock';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import { EventsWorkerDeployer } from './workerDeploy.js';
|
||||
import { eventsWorkerScript } from '../../services/events/workerRuntime.js';
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type EventsInvokeCall,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import { INTERNAL_DEPLOY_TARGET } from '../../drivers/workers/WorkerDriver.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
import {
|
||||
EventsWorkerDeployer,
|
||||
LocalEventsInvokeTransport,
|
||||
type EventsDeployOptions,
|
||||
type EventsDeployOutcome,
|
||||
} from './workerDeploy.js';
|
||||
import {
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
hashContent,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import { handlerSetHash } from '../../services/events/workerSource.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
@@ -46,14 +72,22 @@ interface Owner {
|
||||
type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
|
||||
|
||||
/** Stub layers whose only real dependency is a fresh redis mock. */
|
||||
const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
const makeLayers = (
|
||||
owners: Record<string, Owner | null>,
|
||||
events: Record<string, unknown> = {},
|
||||
) => {
|
||||
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
const targets: Array<Record<string, unknown>> = [];
|
||||
/** Apps whose handler sources were read, once per read. */
|
||||
const sourceReads: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
events: {
|
||||
workerRuntime: true,
|
||||
internalSecret: 'sekret',
|
||||
...events,
|
||||
},
|
||||
// `#namespace()` needs a local-worker shape to resolve without
|
||||
// throwing — this stub never reaches a real Cloudflare or
|
||||
// Miniflare deploy either way.
|
||||
@@ -63,7 +97,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
stores: {
|
||||
eventHandler: {
|
||||
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
|
||||
allForApp: async (appUid: string) => {
|
||||
allForApp: async ({ appUid }: { appUid: string }) => {
|
||||
sourceReads.push(appUid);
|
||||
return [
|
||||
{
|
||||
@@ -86,6 +120,18 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
},
|
||||
},
|
||||
user: {
|
||||
getByUuid: async (uuid: string) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => `uuid-${o?.ownerUserId}` === uuid,
|
||||
);
|
||||
if (!owner) return null;
|
||||
return {
|
||||
id: owner.ownerUserId,
|
||||
uuid,
|
||||
username: `user-${owner.ownerUserId}`,
|
||||
suspended: owner.suspended ?? false,
|
||||
};
|
||||
},
|
||||
getById: async (id: number) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => o?.ownerUserId === id,
|
||||
@@ -103,8 +149,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
services: {},
|
||||
drivers: {
|
||||
workers: {
|
||||
create: async (args: { workerName: string }) => {
|
||||
create: async (args: {
|
||||
workerName: string;
|
||||
[INTERNAL_DEPLOY_TARGET]?: Record<string, unknown>;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
targets.push(args[INTERNAL_DEPLOY_TARGET] ?? {});
|
||||
return { success: true, errors: [] };
|
||||
},
|
||||
},
|
||||
@@ -113,6 +163,7 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
|
||||
return {
|
||||
layers: layers as unknown as Layers,
|
||||
created,
|
||||
targets,
|
||||
redis,
|
||||
sourceReads,
|
||||
};
|
||||
@@ -131,8 +182,8 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
deployer.ensure('app-a', SCRIPT),
|
||||
deployer.ensure('app-b', SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-a'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-b'), SCRIPT),
|
||||
]);
|
||||
expect(a).toBe('deployed');
|
||||
expect(b).toBe('no-owner');
|
||||
@@ -149,7 +200,9 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
String(EVENTS_WORKER_DEPLOYS_PER_HOUR),
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
|
||||
expect(await deployer.ensure(appHandlerScope('app-c'), SCRIPT)).toBe(
|
||||
'throttled',
|
||||
);
|
||||
expect(sourceReads).toEqual([]);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
@@ -164,15 +217,680 @@ describe('EventsWorkerDeployer.ensure', () => {
|
||||
'',
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-e', stale)).toBe('stale');
|
||||
expect(await deployer.ensure(appHandlerScope('app-e'), stale)).toBe(
|
||||
'stale',
|
||||
);
|
||||
expect(sourceReads).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes events.workerCpuMs to the deploy target', async () => {
|
||||
const { layers, targets } = makeLayers(
|
||||
{ 'app-f': { ownerUserId: 6 } },
|
||||
{ workerCpuMs: 50 },
|
||||
);
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-f'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBe(50);
|
||||
});
|
||||
|
||||
it('passes no cpuMs when events.workerCpuMs is unset', async () => {
|
||||
const { layers, targets } = makeLayers({ 'app-g': { ownerUserId: 7 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure(appHandlerScope('app-g'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(targets[0]!.cpuMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deploys normally under the cap', async () => {
|
||||
const { layers } = makeLayers({ 'app-d': { ownerUserId: 4 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.ensure('app-d', SCRIPT)).toBe('deployed');
|
||||
expect(await deployer.ensure(appHandlerScope('app-d'), SCRIPT)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A server's schedules with a deploy gate that records and can refuse. */
|
||||
const withGate = (
|
||||
layers: Layers,
|
||||
refuse: (capped: boolean) => boolean = () => false,
|
||||
) => {
|
||||
const gated: Array<{ script: string; capped: boolean }> = [];
|
||||
const gate = {
|
||||
gateDeploy: async (
|
||||
script: string,
|
||||
options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
): Promise<EventsDeployOutcome> => {
|
||||
gated.push({ script, capped: options.capped });
|
||||
return refuse(options.capped) ? 'busy' : deploy();
|
||||
},
|
||||
};
|
||||
const deployer = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: gate },
|
||||
} as unknown as Layers);
|
||||
return { deployer, gated };
|
||||
};
|
||||
|
||||
describe('the schedules` deploy gate', () => {
|
||||
it('is skipped without schedules, or with schedules that have no gate', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
await expect(
|
||||
new EventsWorkerDeployer(layers).ensure(
|
||||
appHandlerScope('app-x'),
|
||||
SCRIPT,
|
||||
{
|
||||
lane: 'schedule',
|
||||
},
|
||||
),
|
||||
).resolves.toBe('deployed');
|
||||
const gateless = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: { ...layers.services, schedules: {} },
|
||||
} as unknown as Layers);
|
||||
await expect(
|
||||
gateless.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('takes every deploy, capped only for a schedule run', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('deployed');
|
||||
expect(gated).toEqual([
|
||||
{ script: SCRIPT, capped: false },
|
||||
{ script: SCRIPT, capped: true },
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT, SCRIPT]);
|
||||
});
|
||||
|
||||
it('answers a refused schedule run busy, and a delivery still deploys', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer } = withGate(layers, (capped) => capped);
|
||||
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
}),
|
||||
).resolves.toBe('busy');
|
||||
await expect(
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
).resolves.toBe('deployed');
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
|
||||
it('lets deliveries that join a schedule run`s refused deploy deploy once', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
const deliveries = [
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
|
||||
];
|
||||
await expect(scheduled).resolves.toBe('busy');
|
||||
await expect(Promise.all(deliveries)).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
expect(gated.map((g) => g.capped)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('lets a schedule run join a delivery`s deploy in flight', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
|
||||
const { deployer, gated } = withGate(layers, (capped) => capped);
|
||||
|
||||
const delivery = deployer.ensure(appHandlerScope('app-x'), SCRIPT);
|
||||
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
|
||||
lane: 'schedule',
|
||||
});
|
||||
await expect(Promise.all([delivery, scheduled])).resolves.toEqual([
|
||||
'deployed',
|
||||
'deployed',
|
||||
]);
|
||||
expect(gated).toEqual([{ script: SCRIPT, capped: false }]);
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport', () => {
|
||||
it('renders each request as it goes out, after a cold deploy', async () => {
|
||||
const bodies: string[] = [];
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async (request: Request) => {
|
||||
bodies.push(await request.text());
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async () => {
|
||||
resident = true;
|
||||
return 'deployed';
|
||||
},
|
||||
scopeFor: async (appUid: string) => appHandlerScope(appUid),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
let renders = 0;
|
||||
const call: EventsInvokeCall = {
|
||||
script: SCRIPT,
|
||||
appUid: 'app-x',
|
||||
key: 'k',
|
||||
body: '{"n":0}',
|
||||
timeoutMs: 5000,
|
||||
render: () => `{"n":${++renders}}`,
|
||||
};
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deploy-on-miss of an evicted script', () => {
|
||||
it('redeploys the evicted script, then resends the run with the deployed marker', async () => {
|
||||
const { layers, created } = makeLayers({ 'app-m': { ownerUserId: 8 } });
|
||||
// The namespace only has the script once the deploy lands: before
|
||||
// that the dispatcher answers a miss.
|
||||
let resident = false;
|
||||
layers.drivers.workers.create = (async (args: {
|
||||
workerName: string;
|
||||
}) => {
|
||||
created.push(args.workerName);
|
||||
resident = true;
|
||||
return { success: true, errors: [] };
|
||||
}) as never;
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const sent: boolean[] = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
sent.push(headers[EVENTS_DEPLOYED_HEADER] === '1');
|
||||
if (resident)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as typeof undiciFetch;
|
||||
|
||||
const client = new EventsWorkerInvokerClient({
|
||||
events: { workerRuntime: true },
|
||||
} as IConfig);
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
client.setMissHandler((appUid, script, options) =>
|
||||
deployer.ensure(appHandlerScope(appUid), script, options),
|
||||
);
|
||||
|
||||
const result = await client.invoke({
|
||||
script: SCRIPT,
|
||||
appUid: 'app-m',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k',
|
||||
event: {},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
expect(created).toEqual([SCRIPT]);
|
||||
// One miss, then the resend carrying the deployed marker.
|
||||
expect(sent).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
// The redis mock is shared across tests, so each one uses its own app and code.
|
||||
let fresh = 0;
|
||||
const nextApp = () => `app-u${++fresh}`;
|
||||
const userScope = (holder: number, appUid: string) => ({ appUid, holder });
|
||||
const sourceN = (label: string) =>
|
||||
`async () => { await ${label}${++fresh}(); }`;
|
||||
const scriptOf = (name: string, source: string) =>
|
||||
userHandlerScript({ name, sourceHash: hashContent(source) }, '');
|
||||
|
||||
type Sets = Record<number, Array<{ name: string; source: string }>>;
|
||||
|
||||
/** Layers whose handler sets are per holder (0 is the app's own). */
|
||||
const withSets = (layers: Layers, sets: Sets) => {
|
||||
const handlersOf = (scope: { appUid: string; holder: number }) =>
|
||||
(sets[scope.holder] ?? []).map((h) => ({
|
||||
appUid: scope.appUid,
|
||||
name: h.name,
|
||||
source: h.source,
|
||||
sourceHash: hashContent(h.source),
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}));
|
||||
const store = layers.stores.eventHandler as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
store.setForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope).map(({ name, sourceHash }) => ({ name, sourceHash }));
|
||||
store.allForApp = async (scope: { appUid: string; holder: number }) =>
|
||||
handlersOf(scope);
|
||||
};
|
||||
|
||||
const holders = (...ids: number[]) =>
|
||||
Object.fromEntries(
|
||||
ids.map((id) => [`holder-${id}`, { ownerUserId: id }]),
|
||||
) as Record<string, Owner>;
|
||||
|
||||
describe('EventsWorkerDeployer in a user scope', () => {
|
||||
it('deploys the one handler whose script was asked for, as the holder', async () => {
|
||||
const app = nextApp();
|
||||
const a = sourceN('a');
|
||||
const b = sourceN('b');
|
||||
const { layers, created, targets } = makeLayers(holders(10));
|
||||
withSets(layers, {
|
||||
10: [
|
||||
{ name: 'a', source: a },
|
||||
{ name: 'b', source: b },
|
||||
],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const script = scriptOf('b', b);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
|
||||
expect(created).toEqual([script]);
|
||||
const target = targets[0]!;
|
||||
expect(target.tags).toEqual([`events-app:${app}`, 'user:uuid-10']);
|
||||
// One handler's script: the other one's code is not in it.
|
||||
expect(target.source).toContain(b);
|
||||
expect(target.source).not.toContain(a);
|
||||
});
|
||||
|
||||
it('answers a script no handler of the scope matches as stale, before the throttle', async () => {
|
||||
const app = nextApp();
|
||||
const { layers, redis, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source: sourceN('a') }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, app),
|
||||
scriptOf('b', sourceN('b')),
|
||||
),
|
||||
).toBe('stale');
|
||||
expect(created).toEqual([]);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:10:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('answers a scope with no handlers as no-handlers', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
withSets(layers, {});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(
|
||||
await deployer.ensure(
|
||||
userScope(10, nextApp()),
|
||||
scriptOf('a', sourceN('a')),
|
||||
),
|
||||
).toBe('no-handlers');
|
||||
});
|
||||
|
||||
it('does not deploy for a holder that is suspended or gone', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
'holder-10': { ownerUserId: 10, suspended: true },
|
||||
});
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
const app = nextApp();
|
||||
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'no-owner',
|
||||
);
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives each scope its own hourly cap, apart from the app scope', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers({
|
||||
...holders(10, 11),
|
||||
[app]: { ownerUserId: 12 },
|
||||
});
|
||||
withSets(layers, {
|
||||
0: [{ name: 'a', source }],
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
for (let i = 0; i < EVENTS_USER_WORKER_DEPLOYS_PER_HOUR; i++)
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(10, app), script)).toBe(
|
||||
'throttled',
|
||||
);
|
||||
expect(created).toHaveLength(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR);
|
||||
|
||||
// Another holder of the same app, and the app's own scope, still go.
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(appHandlerScope(app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses concurrent deploys of one script across holders into one upload', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
|
||||
await Promise.all([
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(10, app), script),
|
||||
deployer.ensure(userScope(11, app), script),
|
||||
deployer.ensure(userScope(11, nextApp()), script),
|
||||
]);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('names a script for identical code the same under any holder and app', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10, 11));
|
||||
withSets(layers, {
|
||||
10: [{ name: '@x', source }],
|
||||
11: [{ name: '@x', source }],
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('@x', source);
|
||||
|
||||
expect(await deployer.ensure(userScope(10, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.ensure(userScope(11, nextApp()), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the deployed marker', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
deployer: new EventsWorkerDeployer(made.layers),
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('is left by a user-scope deploy with a ttl under a day', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.ensure(userScope(10, app), script);
|
||||
const ttl = await redis.ttl(`ev:deployed:${script}`);
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThan(24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('lets an eager deploy skip the upload another holder already made', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
expect(await deployer.deployEagerly(userScope(10, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('spends no throttle on a skipped eager deploy', async () => {
|
||||
const { deployer, redis, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
await deployer.deployEagerly(userScope(11, app), script);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
expect(await redis.get(`ev:deploys:{${app}}:11:${hour}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('never skips a deploy-on-miss', async () => {
|
||||
const { deployer, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
expect(await deployer.ensure(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
|
||||
it('is not left by an app-scope deploy', async () => {
|
||||
const app = nextApp();
|
||||
const source = sourceN('a');
|
||||
const { layers, redis, created } = makeLayers({
|
||||
[app]: { ownerUserId: 4 },
|
||||
});
|
||||
withSets(layers, { 0: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const script = scriptOf('a', source);
|
||||
await deployer.ensure(appHandlerScope(app), script);
|
||||
expect(created).toEqual([script]);
|
||||
expect(await redis.get(`ev:deployed:${script}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('still deploys eagerly when the marker cannot be read', async () => {
|
||||
const { deployer, redis, created, script, app } = setup();
|
||||
await deployer.deployEagerly(userScope(10, app), script);
|
||||
const get = redis.get.bind(redis);
|
||||
redis.get = (async (key: string) => {
|
||||
if (key.startsWith('ev:deployed:')) throw new Error('down');
|
||||
return get(key);
|
||||
}) as never;
|
||||
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
|
||||
'deployed',
|
||||
);
|
||||
expect(created).toEqual([script, script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeFor', () => {
|
||||
it('reads no holder as the app scope, and resolves a uuid to the user scope', async () => {
|
||||
const { layers } = makeLayers(holders(10));
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
expect(await deployer.scopeFor('app-u')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', '')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 0,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-10')).toEqual({
|
||||
appUid: 'app-u',
|
||||
holder: 10,
|
||||
});
|
||||
expect(await deployer.scopeFor('app-u', 'uuid-99')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalEventsInvokeTransport in a user scope', () => {
|
||||
it('deploys the holder`s script on a miss and refuses an unknown holder', async () => {
|
||||
const source = sourceN('a');
|
||||
const { layers, created } = makeLayers(holders(10));
|
||||
withSets(layers, { 10: [{ name: 'a', source }] });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
let resident = false;
|
||||
const transport = new LocalEventsInvokeTransport(
|
||||
{
|
||||
localworkerservice: {
|
||||
dispatchEventsWorker: async () => {
|
||||
if (!resident) return null;
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'x-puter-events-handled': '1' },
|
||||
});
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
ensure: async (
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
) => {
|
||||
const outcome = await deployer.ensure(
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
resident = outcome === 'deployed';
|
||||
return outcome;
|
||||
},
|
||||
scopeFor: (appUid: string, holder?: string) =>
|
||||
deployer.scopeFor(appUid, holder),
|
||||
} as unknown as EventsWorkerDeployer,
|
||||
);
|
||||
const script = scriptOf('a', source);
|
||||
const call: EventsInvokeCall = {
|
||||
script,
|
||||
appUid: nextApp(),
|
||||
holder: 'uuid-10',
|
||||
key: 'k',
|
||||
body: '{}',
|
||||
timeoutMs: 5000,
|
||||
};
|
||||
|
||||
await expect(transport.send(call)).resolves.toMatchObject({
|
||||
status: 200,
|
||||
});
|
||||
expect(created).toEqual([script]);
|
||||
|
||||
resident = false;
|
||||
await expect(
|
||||
transport.send({ ...call, holder: 'uuid-99' }),
|
||||
).resolves.toMatchObject({ status: null, error: 'deploy: no-holder' });
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent eager deploys of one script', () => {
|
||||
const setup = () => {
|
||||
const source = sourceN('a');
|
||||
const made = makeLayers(holders(10, 11));
|
||||
withSets(made.layers, {
|
||||
10: [{ name: 'a', source }],
|
||||
11: [{ name: 'a', source }],
|
||||
});
|
||||
return {
|
||||
...made,
|
||||
script: scriptOf('a', source),
|
||||
app: nextApp(),
|
||||
};
|
||||
};
|
||||
|
||||
it('upload once when two holders publish the same new code together', async () => {
|
||||
const { layers, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const outcomes = await Promise.all([
|
||||
deployer.deployEagerly(userScope(10, app), script),
|
||||
deployer.deployEagerly(userScope(11, app), script),
|
||||
]);
|
||||
|
||||
expect(outcomes).toEqual(['deployed', 'deployed']);
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('skip the upload when the marker appears while they wait on the gate', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
// Another node's deploy of the script finishes before the gate opens.
|
||||
const gated = new EventsWorkerDeployer({
|
||||
...layers,
|
||||
services: {
|
||||
schedules: {
|
||||
gateDeploy: async (
|
||||
_script: string,
|
||||
_options: { capped: boolean },
|
||||
deploy: () => Promise<EventsDeployOutcome>,
|
||||
) => {
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
return deploy();
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Layers);
|
||||
|
||||
expect(
|
||||
await gated.ensure(userScope(10, app), script, { eager: true }),
|
||||
).toBe('deployed');
|
||||
expect(created).toEqual([]);
|
||||
// A miss still uploads, whatever the marker says.
|
||||
expect(await gated.ensure(userScope(11, app), script)).toBe('deployed');
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
|
||||
it('make a miss that joins an eager deploy deploy after it', async () => {
|
||||
const { layers, redis, created, script, app } = setup();
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
await redis.set(`ev:deployed:${script}`, '1');
|
||||
|
||||
const eager = deployer.ensure(userScope(10, app), script, {
|
||||
eager: true,
|
||||
});
|
||||
const miss = deployer.ensure(userScope(11, app), script);
|
||||
|
||||
expect(await eager).toBe('deployed');
|
||||
expect(await miss).toBe('deployed');
|
||||
// The eager one skipped on the marker; the miss uploaded.
|
||||
expect(created).toEqual([script]);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import type {
|
||||
EventsInvokeAnswer,
|
||||
EventsInvokeCall,
|
||||
InvokeLane,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_INVOKE_PATH,
|
||||
@@ -36,47 +37,89 @@ import {
|
||||
failedAnswer,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { puterServices } from '../../services/index.js';
|
||||
import type { IExtensionServiceInstances } from '../../services/types.js';
|
||||
import {
|
||||
eventsInvokeKey,
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
userHandlerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
generateEventsWorkerSource,
|
||||
handlerSetHash,
|
||||
} from '../../services/events/workerSource.js';
|
||||
import {
|
||||
appHandlerScope,
|
||||
type HandlerScope,
|
||||
} from '../../stores/events/EventHandlerStore.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
import {
|
||||
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
|
||||
EVENTS_WORKER_DEPLOYS_PER_HOUR,
|
||||
} from './limits.js';
|
||||
|
||||
/**
|
||||
* Deploys an app's events worker from its published handlers.
|
||||
*
|
||||
* Deployed the first time a delivery needs it and again on eviction — a publish
|
||||
* only writes rows. Scripts carry no worker token and no `subdomains` row: not
|
||||
* one of the owner's workers, not addressable from the internet, not listed or
|
||||
* deleted as if it were. A republished set leaves the old script behind rather
|
||||
* than deleting it; redeploying a still-live one is always safe.
|
||||
* only writes rows (a user-scope publish also deploys eagerly). An app's scope
|
||||
* is one script for the whole set; a user scope is one script per handler,
|
||||
* deployed as the holder. Scripts carry no worker token and no `subdomains`
|
||||
* row: not one of the owner's workers, not addressable from the internet, not
|
||||
* listed or deleted as if it were. A republished set leaves the old script
|
||||
* behind rather than deleting it; redeploying a still-live one is always safe.
|
||||
*/
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed'
|
||||
| 'busy';
|
||||
|
||||
/** Whose call asked for the deploy: a schedule run's is held to a share. */
|
||||
export interface EventsDeployOptions {
|
||||
lane?: InvokeLane;
|
||||
/**
|
||||
* A user-scope publish's deploy, which skips the upload when the script's
|
||||
* deployed marker is already set. A deploy-on-miss is never eager.
|
||||
*/
|
||||
eager?: boolean;
|
||||
}
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
services: LayerInstances<typeof puterServices> &
|
||||
Pick<IExtensionServiceInstances, 'schedules'>;
|
||||
drivers: LayerInstances<typeof puterDrivers>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ev:deploys:{<appUid>}:<hour>` bucket TTL — comfortably past the hour it
|
||||
* counts.
|
||||
* `ev:deploys:{<appUid>}[:<holder>]:<hour>` bucket TTL — comfortably past the
|
||||
* hour it counts.
|
||||
*/
|
||||
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/** How long `ev:deployed:<script>` says a script was uploaded. Under a day. */
|
||||
const DEPLOYED_MARKER_TTL_SECONDS = 20 * 60 * 60;
|
||||
|
||||
const deployedMarkerKey = (script: string): string => `ev:deployed:${script}`;
|
||||
|
||||
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
|
||||
|
||||
interface InFlightDeploy {
|
||||
/** A schedule run's, which the region's deploy slots may refuse. */
|
||||
capped: boolean;
|
||||
eager: boolean;
|
||||
promise: Promise<EventsDeployOutcome>;
|
||||
}
|
||||
|
||||
interface DeployResult {
|
||||
success?: boolean;
|
||||
errors?: unknown[];
|
||||
@@ -85,7 +128,7 @@ interface DeployResult {
|
||||
export class EventsWorkerDeployer {
|
||||
readonly #layers: DeployLayers;
|
||||
/** Collapses the burst of misses one cold app produces into one deploy. */
|
||||
readonly #inFlight = new Map<string, Promise<EventsDeployOutcome>>();
|
||||
readonly #inFlight = new Map<string, InFlightDeploy>();
|
||||
|
||||
constructor(layers: DeployLayers) {
|
||||
this.#layers = layers;
|
||||
@@ -100,44 +143,188 @@ export class EventsWorkerDeployer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy `script`, which must be what the app's handlers currently hash to.
|
||||
* A request for anything else is `stale` — the set moved on, and the next
|
||||
* delivery resolves the new script rather than this one being revived.
|
||||
* Deploy `script`, which must be what the scope's handlers currently hash
|
||||
* to: the whole set for an app, one handler for a user scope. A request for
|
||||
* anything else is `stale` — the handlers moved on, and the next delivery
|
||||
* resolves the new script rather than this one being revived. Always
|
||||
* uploads; a deploy-on-miss means whatever said it was there is wrong.
|
||||
*/
|
||||
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
|
||||
const key = `${appUid}|${script}`;
|
||||
ensure(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions = {},
|
||||
): Promise<EventsDeployOutcome> {
|
||||
// A user scope's script is the same bytes whoever holds it, so
|
||||
// concurrent deploys of it from any holder or app are one.
|
||||
const key = isUserScope(scope)
|
||||
? `user|${script}`
|
||||
: `${scope.appUid}|${scope.holder}|${script}`;
|
||||
const capped = options.lane === 'schedule';
|
||||
const existing = this.#inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const started = this.#deploy(appUid, script).finally(() =>
|
||||
this.#inFlight.delete(key),
|
||||
if (!existing) return this.#start(key, scope, script, options);
|
||||
// An eager deploy may have skipped the upload, which a miss must not
|
||||
// take for done.
|
||||
if (existing.eager && !options.eager)
|
||||
return this.#afterEager(
|
||||
existing.promise,
|
||||
key,
|
||||
scope,
|
||||
script,
|
||||
options,
|
||||
);
|
||||
if (capped || !existing.capped) return existing.promise;
|
||||
return this.#afterCapped(existing.promise, key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ensure} for the deploy a user-scope publish starts, which skips the
|
||||
* upload when another holder's deploy of the same script left its marker.
|
||||
* Only this path reads the marker.
|
||||
*/
|
||||
async deployEagerly(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
if (await this.#markedDeployed(script)) return 'deployed';
|
||||
return this.ensure(scope, script, { eager: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope a script request names: the app's own, or the user scope of
|
||||
* `holder` (a user uuid). Null for a holder that does not exist.
|
||||
*/
|
||||
async scopeFor(
|
||||
appUid: string,
|
||||
holder?: string | null,
|
||||
): Promise<HandlerScope | null> {
|
||||
if (!holder) return appHandlerScope(appUid);
|
||||
const user = await this.#layers.stores.user.getByUuid(holder);
|
||||
if (!user?.id) return null;
|
||||
return { appUid, holder: Number(user.id) };
|
||||
}
|
||||
|
||||
#start(
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const entry: InFlightDeploy = {
|
||||
capped: options.lane === 'schedule',
|
||||
eager: options.eager === true,
|
||||
promise: this.#gated(scope, script, options).finally(() => {
|
||||
if (this.#inFlight.get(key) === entry)
|
||||
this.#inFlight.delete(key);
|
||||
}),
|
||||
};
|
||||
this.#inFlight.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** A miss that found an eager deploy in flight deploys after it. */
|
||||
async #afterEager(
|
||||
eager: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
await eager;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.eager) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A delivery that joined a schedule run's deploy: `busy` means no deploy
|
||||
* started, and a delivery is never refused a slot, so it asks again.
|
||||
*/
|
||||
async #afterCapped(
|
||||
capped: Promise<EventsDeployOutcome>,
|
||||
key: string,
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const outcome = await capped;
|
||||
if (outcome !== 'busy') return outcome;
|
||||
const current = this.#inFlight.get(key);
|
||||
if (current && !current.capped) return current.promise;
|
||||
return this.#start(key, scope, script, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every deploy goes through the schedules' deploy gate when a server has
|
||||
* one: a per-script lock, so a burst of runs of an evicted script deploys
|
||||
* it once, and a region-wide cap that a schedule run's deploy (`capped`)
|
||||
* may be refused with `busy`. A delivery's deploy is never refused.
|
||||
*/
|
||||
#gated(
|
||||
scope: HandlerScope,
|
||||
script: string,
|
||||
options: EventsDeployOptions,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const deploy = () =>
|
||||
this.#deploy(scope, script, options.eager === true);
|
||||
return (
|
||||
this.#layers.services.schedules?.gateDeploy?.(
|
||||
script,
|
||||
{ capped: options.lane === 'schedule' },
|
||||
deploy,
|
||||
) ?? deploy()
|
||||
);
|
||||
this.#inFlight.set(key, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
async #deploy(
|
||||
appUid: string,
|
||||
handlerScope: HandlerScope,
|
||||
script: string,
|
||||
eager: boolean,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { appUid } = handlerScope;
|
||||
const { stores, drivers } = this.#layers;
|
||||
const scope = eventsWorkerScope(this.#layers.config);
|
||||
const backend = eventsWorkerScope(this.#layers.config);
|
||||
const userScope = isUserScope(handlerScope);
|
||||
|
||||
// Names and hashes are enough to tell a stale request, so the set's
|
||||
// sources are read and built only once the throttle has let it through.
|
||||
const set = await stores.eventHandler.setForApp(appUid);
|
||||
const set = await stores.eventHandler.setForApp(handlerScope);
|
||||
if (set.length === 0) return 'no-handlers';
|
||||
// Two apps that published byte-identical sets share one script, which
|
||||
// is sound: the source is the same, and everything per-app arrives with
|
||||
// the invocation. Whoever deploys first owns it.
|
||||
if (eventsWorkerScript(handlerSetHash(set), scope) !== script)
|
||||
// the invocation. Whoever deploys first owns it. A user scope's script
|
||||
// is one handler's, so it is shared the same way across holders.
|
||||
const wanted = userScope
|
||||
? set.find((entry) => userHandlerScript(entry, backend) === script)
|
||||
: undefined;
|
||||
if (
|
||||
userScope
|
||||
? !wanted
|
||||
: eventsWorkerScript(handlerSetHash(set), backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
// Asked again inside the gate, where another deploy of this script may
|
||||
// have just finished.
|
||||
if (eager && userScope && (await this.#markedDeployed(script)))
|
||||
return 'deployed';
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
if (await this.#tooManyDeploysThisHour(handlerScope))
|
||||
return 'throttled';
|
||||
|
||||
const all = await stores.eventHandler.allForApp(handlerScope);
|
||||
const handlers = wanted
|
||||
? all.filter(
|
||||
(handler) =>
|
||||
handler.name === wanted.name &&
|
||||
handler.sourceHash === wanted.sourceHash,
|
||||
)
|
||||
: all;
|
||||
const generated = generateEventsWorkerSource(handlers);
|
||||
// The set can move between the two reads.
|
||||
if (eventsWorkerScript(generated.setHash, scope) !== script)
|
||||
if (
|
||||
handlers.length === 0 ||
|
||||
eventsWorkerScript(generated.setHash, backend) !== script
|
||||
)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
console.error(
|
||||
@@ -151,7 +338,7 @@ export class EventsWorkerDeployer {
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
const owner = await this.#owner(handlerScope);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
const secret = this.#layers.config.events?.internalSecret;
|
||||
@@ -180,6 +367,7 @@ export class EventsWorkerDeployer {
|
||||
runtime: 'events',
|
||||
source: generated.source,
|
||||
omitOwnerToken: true,
|
||||
cpuMs: this.#layers.config.events?.workerCpuMs,
|
||||
// What the script compares an invocation against.
|
||||
// Derived from the script name, so a redeploy of
|
||||
// the same set binds the same key.
|
||||
@@ -210,34 +398,74 @@ export class EventsWorkerDeployer {
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
if (userScope) await this.#markDeployed(script);
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this app has already deployed `EVENTS_WORKER_DEPLOYS_PER_HOUR`
|
||||
* times this hour. A flapping set (publish, remove, republish) provisions
|
||||
* upstream on every miss, and this is the ceiling on that loop.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(appUid: string): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const key = `ev:deploys:{${appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return count > EVENTS_WORKER_DEPLOYS_PER_HOUR;
|
||||
async #markedDeployed(script: string): Promise<boolean> {
|
||||
try {
|
||||
return !!(await this.#layers.clients.redis.get(
|
||||
deployedMarkerKey(script),
|
||||
));
|
||||
} catch (err) {
|
||||
console.warn('[events] deployed marker unreadable', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The app owner, as an actor the deploy can run as. */
|
||||
async #markDeployed(script: string): Promise<void> {
|
||||
try {
|
||||
await this.#layers.clients.redis.set(
|
||||
deployedMarkerKey(script),
|
||||
'1',
|
||||
'EX',
|
||||
DEPLOYED_MARKER_TTL_SECONDS,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[events] could not mark a script deployed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this scope has already deployed its hourly cap this hour. A
|
||||
* flapping set (publish, remove, republish) provisions upstream on every
|
||||
* miss, and this is the ceiling on that loop. An app's counter is per app;
|
||||
* a user scope has its own, so one user cannot spend another's.
|
||||
*/
|
||||
async #tooManyDeploysThisHour(scope: HandlerScope): Promise<boolean> {
|
||||
const redis = this.#layers.clients.redis;
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
const userScope = isUserScope(scope);
|
||||
const key = userScope
|
||||
? `ev:deploys:{${scope.appUid}}:${scope.holder}:${hour}`
|
||||
: `ev:deploys:{${scope.appUid}}:${hour}`;
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
|
||||
return (
|
||||
count >
|
||||
(userScope
|
||||
? EVENTS_USER_WORKER_DEPLOYS_PER_HOUR
|
||||
: EVENTS_WORKER_DEPLOYS_PER_HOUR)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user a scope's deploy runs as, as an actor: the app's owner, or the
|
||||
* holder of a user scope.
|
||||
*/
|
||||
async #owner(
|
||||
appUid: string,
|
||||
scope: HandlerScope,
|
||||
): Promise<
|
||||
| (Actor & { user: { id: number; uuid: string; username: string } })
|
||||
| null
|
||||
> {
|
||||
const { stores } = this.#layers;
|
||||
const app = await stores.app.getByUid(appUid);
|
||||
const ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
let ownerUserId = scope.holder;
|
||||
if (!isUserScope(scope)) {
|
||||
const app = await stores.app.getByUid(scope.appUid);
|
||||
ownerUserId = Number(app?.owner_user_id);
|
||||
if (!app || !Number.isFinite(ownerUserId)) return null;
|
||||
}
|
||||
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
@@ -299,6 +527,8 @@ export class LocalEventsInvokeTransport {
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
|
||||
// Built per attempt, so a send time stamped into the body is taken
|
||||
// after a cold deploy rather than before it.
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
@@ -308,7 +538,7 @@ export class LocalEventsInvokeTransport {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-events-key': call.key,
|
||||
},
|
||||
body: call.body,
|
||||
body: call.render ? call.render() : call.body,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -316,12 +546,20 @@ export class LocalEventsInvokeTransport {
|
||||
const resident = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (resident !== null) return resident;
|
||||
|
||||
const outcome = await this.#deployer.ensure(
|
||||
const scope = await this.#deployer.scopeFor(
|
||||
call.appUid,
|
||||
call.script,
|
||||
call.holder,
|
||||
);
|
||||
if (!scope) return { status: null, error: 'deploy: no-holder' };
|
||||
const outcome = await this.#deployer.ensure(scope, call.script, {
|
||||
lane: call.lane,
|
||||
});
|
||||
if (outcome !== 'deployed')
|
||||
return { status: null, error: `deploy: ${outcome}` };
|
||||
return {
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
...(outcome === 'busy' ? { deployBusy: true } : {}),
|
||||
};
|
||||
|
||||
const retried = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (retried === null)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ 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 { embedFrameAncestors, embedHostOrigin } from './HomepageController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -78,6 +79,7 @@ interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
contentType?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
@@ -86,6 +88,7 @@ const makeReq = (init: {
|
||||
actor?: Actor;
|
||||
hostname?: string;
|
||||
protocol?: string;
|
||||
originalUrl?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
body: {},
|
||||
@@ -93,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,
|
||||
@@ -130,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 };
|
||||
};
|
||||
@@ -213,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 ──────────────────────────────────────────────────────
|
||||
@@ -359,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 () => {
|
||||
@@ -433,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,3 +560,46 @@ describe('HomepageController GET /sw.js', () => {
|
||||
expect(route?.options.subdomain).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedFrameAncestors', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
};
|
||||
|
||||
it('names a sibling subdomain that embeds the browser', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://sheets.example.test/doc/1'),
|
||||
).toBe("'self' https://sheets.example.test");
|
||||
});
|
||||
|
||||
it('refuses the configured API host even off the api label', () => {
|
||||
expect(
|
||||
embedFrameAncestors(config, 'https://backend.example.test/x'),
|
||||
).toBe("'self'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedHostOrigin', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
static_hosting_domain: 'site.example.test',
|
||||
};
|
||||
|
||||
it.each([
|
||||
['https://sheets.example.test/doc/1', 'https://sheets.example.test'],
|
||||
['https://backend.example.test/x', null],
|
||||
['https://api.example.test/', null],
|
||||
['https://someone.site.example.test/', null],
|
||||
['https://a.sheets.example.test/', null],
|
||||
['http://sheets.example.test/', null],
|
||||
['https://example.test/', null],
|
||||
['not a url', null],
|
||||
[undefined, null],
|
||||
])('%s frames as %s', (referer, expected) => {
|
||||
expect(embedHostOrigin(config, referer)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -20,14 +20,39 @@
|
||||
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 {
|
||||
isReservedSubdomain,
|
||||
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, 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',
|
||||
'show-open-file-picker',
|
||||
'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>`. */
|
||||
export const APP_META_DESCRIPTION_MAX = 150;
|
||||
|
||||
@@ -43,9 +68,9 @@ const APP_LANDING_LIMIT = {
|
||||
};
|
||||
|
||||
/**
|
||||
* App descriptions can run long; search snippets and social cards want a
|
||||
* short blurb. Collapses whitespace, then cuts at a word boundary with an
|
||||
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
* App descriptions can run long; search snippets and social cards want a short
|
||||
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
|
||||
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
|
||||
*/
|
||||
export function appMetaDescription(text: string): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -56,6 +81,64 @@ export function appMetaDescription(text: string): string {
|
||||
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
|
||||
}
|
||||
|
||||
type EmbedFramingConfig = Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The origin of a first-party page that may frame an `/embed/*` page, named
|
||||
* exactly from the request's referrer, or null. A wildcard over `*.<domain>`
|
||||
* would also admit user-hosted sites whenever the hosting domains sit under it,
|
||||
* so only a single label under `<domain>` qualifies, and not one this server
|
||||
* answers itself (the API, WebDAV, SDK and native-app hosts).
|
||||
*/
|
||||
export function embedHostOrigin(
|
||||
config: EmbedFramingConfig,
|
||||
referer: string | undefined,
|
||||
): string | null {
|
||||
const gui = new URL(config.origin ?? `https://${config.domain}`);
|
||||
let parent: URL;
|
||||
try {
|
||||
parent = new URL(referer ?? '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parent.hostname.toLowerCase();
|
||||
const label = subdomainLabelOf(host, config.domain);
|
||||
const hostingDomains = [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
]
|
||||
.map((d) => (d ?? '').toLowerCase().split(':')[0])
|
||||
.filter(Boolean);
|
||||
const allowed =
|
||||
parent.protocol === gui.protocol &&
|
||||
parent.port === gui.port &&
|
||||
label !== undefined &&
|
||||
!isReservedSubdomain(label) &&
|
||||
host !== new URL(config.api_base_url).hostname &&
|
||||
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||
return allowed ? parent.origin : null;
|
||||
}
|
||||
|
||||
/** `frame-ancestors` for an `/embed/*` page: the GUI and {@link embedHostOrigin}. */
|
||||
export function embedFrameAncestors(
|
||||
config: EmbedFramingConfig,
|
||||
referer: string | undefined,
|
||||
): string {
|
||||
const host = embedHostOrigin(config, referer);
|
||||
return host ? `'self' ${host}` : "'self'";
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes that render the Puter GUI shell, plus a catch-all static fallback
|
||||
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
|
||||
@@ -89,7 +172,7 @@ export class HomepageController extends PuterController {
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
metaOverrides: Partial<PageMeta> = {},
|
||||
launch: LaunchOptions = {},
|
||||
popupFrame?: PopupFrame,
|
||||
) => {
|
||||
const meta = { ...defaultMeta(req), ...metaOverrides };
|
||||
const actor =
|
||||
@@ -98,30 +181,133 @@ export class HomepageController extends PuterController {
|
||||
actor?: Parameters<typeof homepage.send>[0]['actor'];
|
||||
}
|
||||
).actor ?? null;
|
||||
await homepage.send({ req, res, actor }, meta, launch);
|
||||
await homepage.send(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
actor,
|
||||
guiTokenDigest: this.#guiTokenDigest(req, actor),
|
||||
},
|
||||
meta,
|
||||
popupFrame,
|
||||
);
|
||||
};
|
||||
|
||||
// -- Root + path-aliased shell routes ------------------------
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
|
||||
/**
|
||||
* Headers shared by the `/embed/*` pages; false when the request was
|
||||
* redirected instead. SDK bootstrap parameters must not change an
|
||||
* embed's account or API origin, so a query string is dropped. Checked
|
||||
* on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
*/
|
||||
const prepareEmbed = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
path: string,
|
||||
): boolean => {
|
||||
if (req.originalUrl.includes('?')) {
|
||||
res.redirect(path);
|
||||
return false;
|
||||
}
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return true;
|
||||
};
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/apps')) return;
|
||||
// Shown to visitors without a session; a signed-in embed fetches
|
||||
// its own list, so a failure here only costs signed-out visitors.
|
||||
const guestApps = await this.services.recommendedApps
|
||||
.getRecommendedApps()
|
||||
.catch((): Array<Record<string, unknown>> => []);
|
||||
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
|
||||
});
|
||||
|
||||
router.get('/embed/feedback', {}, (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/feedback')) return;
|
||||
// The feedback is recorded against this origin, which
|
||||
// frame-ancestors has just pinned to the actual framing page.
|
||||
res.send(
|
||||
homepage.renderFeedbackEmbed({
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
req.get('referer'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The GUI runs the popup's own boot, answering the framing page
|
||||
// instead of an opener.
|
||||
for (const action of FRAMED_POPUP_ACTIONS) {
|
||||
const embedPath = `/embed/${action}`;
|
||||
router.get(embedPath, {}, async (req, res) => {
|
||||
// Routes match case-insensitively and with a trailing slash,
|
||||
// and the shell boots the desktop for any other spelling, so
|
||||
// only the exact one may be framed.
|
||||
if (req.originalUrl !== embedPath) {
|
||||
res.redirect(embedPath);
|
||||
return;
|
||||
}
|
||||
if (!prepareEmbed(req, res, embedPath)) return;
|
||||
await sendShell(
|
||||
req,
|
||||
res,
|
||||
{},
|
||||
{
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
req.get('referer'),
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -176,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 --
|
||||
@@ -224,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,
|
||||
|
||||
@@ -244,7 +244,10 @@ describe('OIDCController GET /auth/oidc/providers', () => {
|
||||
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
|
||||
// We configured a single `custom` provider; the live service
|
||||
// walks the config and returns its id.
|
||||
expect(captured.body).toEqual({ providers: ['custom'] });
|
||||
expect(captured.body).toEqual({
|
||||
providers: ['custom'],
|
||||
signupOidcOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,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',
|
||||
@@ -397,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',
|
||||
@@ -473,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'),
|
||||
@@ -1617,7 +1602,7 @@ describe('OIDCController signup callback', () => {
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
});
|
||||
|
||||
it('creates a fresh user without oidc_switched on first signup', async () => {
|
||||
it('creates a fresh user on first signup', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
@@ -1643,11 +1628,9 @@ describe('OIDCController signup callback', () => {
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
// Fresh creation — must NOT advertise "you've actually been signed in".
|
||||
expect(captured.redirectUrl).not.toContain('oidc_switched=login');
|
||||
});
|
||||
|
||||
it('adds oidc_switched=login when signup hits an already-linked account', async () => {
|
||||
it('signs in to an already-linked account on signup, returning as a login would', async () => {
|
||||
// Seed an account linked on (provider, sub) so the signup callback
|
||||
// resolves to `linked-sub` rather than creating a new user.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -1683,7 +1666,7 @@ describe('OIDCController signup callback', () => {
|
||||
res,
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toContain('oidc_switched=login');
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
});
|
||||
|
||||
it('redirects to action=signup with auth_error when user resolution fails (unconfirmed email)', async () => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,9 +394,12 @@ export class OIDCController extends PuterController {
|
||||
},
|
||||
},
|
||||
async (_req: Request, res: Response) => {
|
||||
const providers =
|
||||
await this.services.oidc.getEnabledProviderIds();
|
||||
res.json({ providers });
|
||||
const [providers, policy] = await Promise.all([
|
||||
this.services.oidc.getEnabledProviderIds(),
|
||||
this.services.signup.signupPolicy(),
|
||||
]);
|
||||
// The signup window drops its email form when this is set.
|
||||
res.json({ providers, signupOidcOnly: policy.oidcOnly });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -435,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;
|
||||
@@ -685,14 +685,7 @@ export class OIDCController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// If we landed on an existing account (either via the
|
||||
// provider_sub or via email match), signal the GUI so it can
|
||||
// render a "signed in" flow rather than "account created".
|
||||
const extra =
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(req, res, user, stateDecoded, extra);
|
||||
await this.#finishLogin(req, res, user, stateDecoded);
|
||||
};
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
@@ -831,17 +824,14 @@ if (window.opener) {
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
}
|
||||
| { user: import('../../stores/user/UserStore.js').UserRow }
|
||||
> {
|
||||
// 1. Existing provider/sub link.
|
||||
const linked = await this.services.oidc.findUserByProviderSub(
|
||||
provider,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (linked) return { user: linked, origin: 'linked-sub' };
|
||||
if (linked) return { user: linked };
|
||||
|
||||
// 2/3. Email match branch.
|
||||
const claimedEmail =
|
||||
@@ -878,7 +868,7 @@ if (window.opener) {
|
||||
error: outcome.error ?? 'Failed to link provider.',
|
||||
};
|
||||
}
|
||||
return { user: byEmail, origin: 'linked-email' };
|
||||
return { user: byEmail };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,7 +898,7 @@ if (window.opener) {
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
return { user: outcome.user };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
@@ -997,7 +987,6 @@ if (window.opener) {
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
@@ -1015,7 +1004,6 @@ if (window.opener) {
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('OIDC account second-factor login', () => {
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
redirectUrl: `${env.origin}/dashboard`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { inRequestScope, setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
@@ -201,6 +201,7 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
store: true,
|
||||
truncation: 'auto',
|
||||
service_tier: 'default',
|
||||
@@ -226,6 +227,9 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
// Forwarded as sent; the provider keeps only the fields it
|
||||
// accepts.
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
// forced false regardless of the client's request.
|
||||
store: false,
|
||||
truncation: 'auto',
|
||||
@@ -359,13 +363,18 @@ describe('PuterAIController.openaiResponses input normalization', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
'a bare string part',
|
||||
{ type: 'input_text', text: 'typed text' },
|
||||
{
|
||||
type: 'input_text',
|
||||
text: 'typed text',
|
||||
prompt_cache_breakpoint: { mode: 'explicit', ttl: '12h' },
|
||||
},
|
||||
{ type: 'output_text', text: 'echoed text' },
|
||||
{
|
||||
type: 'input_image',
|
||||
detail: 'high',
|
||||
image_url: 'https://img.test/a.png',
|
||||
file_id: 'file_img',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
{ type: 'input_audio', input_audio: { data: 'AAA' } },
|
||||
{
|
||||
@@ -386,13 +395,18 @@ describe('PuterAIController.openaiResponses input normalization', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'a bare string part' },
|
||||
{ type: 'text', text: 'typed text' },
|
||||
{
|
||||
type: 'text',
|
||||
text: 'typed text',
|
||||
prompt_cache_breakpoint: { mode: 'explicit', ttl: '12h' },
|
||||
},
|
||||
{ type: 'text', text: 'echoed text' },
|
||||
{
|
||||
type: 'image_url',
|
||||
detail: 'high',
|
||||
image_url: { url: 'https://img.test/a.png' },
|
||||
file_id: 'file_img',
|
||||
prompt_cache_breakpoint: { mode: 'explicit' },
|
||||
},
|
||||
{ type: 'input_audio', input_audio: { data: 'AAA' } },
|
||||
{
|
||||
@@ -639,6 +653,33 @@ describe('PuterAIController.openaiResponses output items', () => {
|
||||
// -- /openai/v1/chat/completions edges -------------------------------
|
||||
|
||||
describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
it('forwards the prompt-cache fields to the driver', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const args = completeSpy.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('derives tool_calls from tool_use content blocks when the message has none', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
|
||||
@@ -49,8 +49,8 @@ import type { RouteOptions } from '../../core/http/index.js';
|
||||
import {
|
||||
COUNT_TOKENS,
|
||||
type ChatCompletionDriver,
|
||||
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/ai/shared/aiLimits.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { inRequestScope, setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
|
||||
@@ -30,25 +30,28 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
COUNT_TOKENS,
|
||||
type ChatCompletionDriver,
|
||||
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatMessageResult,
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
UsageDetails,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
} from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import {
|
||||
outputFormatFromResponseFormat,
|
||||
rejectStatefulResponsesFields,
|
||||
toolChoiceFromWire,
|
||||
} from '../../drivers/ai-chat/utils/openaiParams.js';
|
||||
} from '../../drivers/ai/protocols/openaiCompatible/openaiParams.js';
|
||||
import {
|
||||
promoteStopForToolCalls,
|
||||
toFinishReason,
|
||||
} from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
} from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import {
|
||||
AI_CONCURRENT,
|
||||
AI_RATE_LIMIT,
|
||||
} from '../../drivers/ai/shared/aiLimits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { parseAnthropicRequest, toAnthropicMessage } from './anthropicWire.js';
|
||||
import { pipeNdjsonStream } from './ndjsonStream.js';
|
||||
@@ -447,6 +450,7 @@ export class PuterAIController extends PuterController {
|
||||
body.reasoning_effort as ICompleteArguments['reasoning_effort'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
...openaiCompatProvider(body),
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
@@ -842,15 +846,7 @@ export class PuterAIController extends PuterController {
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
// forced off regardless of what the caller sent — OpenAI
|
||||
// defaults `store` to true, which would persist state server-side
|
||||
// on OpenAI's end that this route gives no way to retrieve.
|
||||
@@ -1343,6 +1339,33 @@ const openaiCompatProvider = (
|
||||
return { provider: DEFAULTS.openaiChat };
|
||||
};
|
||||
|
||||
/**
|
||||
* The prompt-cache fields both OpenAI-shaped routes forward as sent; the
|
||||
* provider checks the values.
|
||||
*/
|
||||
const promptCacheArgsFromBody = (
|
||||
body: Record<string, unknown>,
|
||||
): Pick<
|
||||
ICompleteArguments,
|
||||
'prompt_cache_key' | 'prompt_cache_retention' | 'prompt_cache_options'
|
||||
> => ({
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_options
|
||||
? {
|
||||
prompt_cache_options:
|
||||
body.prompt_cache_options as ICompleteArguments['prompt_cache_options'],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const randomId = (): string => crypto.randomUUID().replace(/-/g, '');
|
||||
const generateId = (prefix: string): string => `${prefix}_${randomId()}`;
|
||||
|
||||
@@ -1590,12 +1613,24 @@ const parseJsonMaybe = (value: unknown): unknown => {
|
||||
}
|
||||
};
|
||||
|
||||
/** An OpenAI `prompt_cache_breakpoint` marker, kept on the translated part. */
|
||||
const cacheBreakpointOf = (
|
||||
p: Record<string, unknown>,
|
||||
): { prompt_cache_breakpoint?: unknown } =>
|
||||
p.prompt_cache_breakpoint && typeof p.prompt_cache_breakpoint === 'object'
|
||||
? { prompt_cache_breakpoint: p.prompt_cache_breakpoint }
|
||||
: {};
|
||||
|
||||
const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
if (typeof part === 'string') return { type: 'text', text: part };
|
||||
if (!part || typeof part !== 'object') return { type: 'text', text: '' };
|
||||
const p = part as Record<string, unknown>;
|
||||
if (p.type === 'input_text' || p.type === 'output_text') {
|
||||
return { type: 'text', text: String(p.text ?? '') };
|
||||
return {
|
||||
type: 'text',
|
||||
text: String(p.text ?? ''),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
if (p.type === 'input_image') {
|
||||
return {
|
||||
@@ -1603,6 +1638,7 @@ const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
...(p.detail ? { detail: p.detail } : {}),
|
||||
...(p.image_url ? { image_url: { url: p.image_url } } : {}),
|
||||
...(p.file_id ? { file_id: p.file_id } : {}),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
if (p.type === 'input_audio')
|
||||
@@ -1614,6 +1650,7 @@ const normalizeContentPart = (part: unknown): Record<string, unknown> => {
|
||||
...(p.file_id ? { file_id: p.file_id } : {}),
|
||||
...(p.file_url ? { file_url: p.file_url } : {}),
|
||||
...(p.filename ? { filename: p.filename } : {}),
|
||||
...cacheBreakpointOf(p),
|
||||
};
|
||||
}
|
||||
return p;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IChatMessageResult } from '../../drivers/ai-chat/types.js';
|
||||
import type { IChatMessageResult } from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import {
|
||||
anthropicUsage,
|
||||
parseAnthropicRequest,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { sanitizeCacheControl } from '../../drivers/ai-chat/providers/claude/anthropicPolicy.js';
|
||||
import { sanitizeCacheControl } from '../../drivers/ai/providers/claude/chat/anthropicPolicy.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatMessageResult,
|
||||
@@ -42,9 +42,9 @@ import type {
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
UsageDetails,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { needsOpenAICoercion } from '../../drivers/ai-chat/utils/normalizeToOpenAI.js';
|
||||
import { fromFinishReason } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
} from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import { needsOpenAICoercion } from '../../drivers/ai/interfaces/chat/utils/normalizeToOpenAI.js';
|
||||
import { fromFinishReason } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
|
||||
|
||||
const toStringOrEmpty = (v: unknown): string =>
|
||||
typeof v === 'string' ? v : '';
|
||||
|
||||
Loaded 100 of 1347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user