mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 18:34:51 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a78736bb | ||
|
|
9b465aba9f | ||
|
|
0edee23dd9 | ||
|
|
99f3037110 | ||
|
|
7ab48c590e | ||
|
|
6146ab9dd3 | ||
|
|
c8f4906af4 | ||
|
|
435629afc5 | ||
|
|
e132f765d6 | ||
|
|
4e45fdc172 | ||
|
|
f6b519874e | ||
|
|
59be4ad237 | ||
|
|
9121e66c3f | ||
|
|
afb5b0d82a | ||
|
|
a60a4a98b4 | ||
|
|
81d15f412b | ||
|
|
f5b2365e83 | ||
|
|
f3943f6a55 | ||
|
|
7a02fa7741 | ||
|
|
da68074401 | ||
|
|
9f615cc632 | ||
|
|
8a70fa2e5c | ||
|
|
9bb5bffd01 | ||
|
|
d7877359d4 | ||
|
|
cdc524f504 | ||
|
|
8b872545dc | ||
|
|
52f9fa57a6 | ||
|
|
eb28012897 | ||
|
|
e5f322f68c | ||
|
|
ab3bba5525 | ||
|
|
9f3c658975 | ||
|
|
bd19fd18ec | ||
|
|
41a05b68fd | ||
|
|
0c6045ed90 | ||
|
|
00afea1285 | ||
|
|
84368f8d02 | ||
|
|
86050f131b | ||
|
|
e50f2911b6 | ||
|
|
414c121ae4 | ||
|
|
1d6d0da950 | ||
|
|
72889d72c8 | ||
|
|
8a6daee9ab | ||
|
|
e980b131a0 | ||
|
|
1cb5472c4c | ||
|
|
927317bc4e | ||
|
|
a441e7f748 | ||
|
|
2784feb47d | ||
|
|
2f70cd1a14 | ||
|
|
b1d3a7c191 | ||
|
|
092a9245ff | ||
|
|
e6cedde732 | ||
|
|
9b4d8f11d4 | ||
|
|
bf4e810f9c | ||
|
|
89a4947f68 | ||
|
|
9ebe1092f1 | ||
|
|
1caf3e121f | ||
|
|
3620f7bd85 | ||
|
|
e48802252e | ||
|
|
da65b7f569 | ||
|
|
ded3d3695b | ||
|
|
e451d507ad | ||
|
|
7099cadc6f | ||
|
|
73cb60f5ae | ||
|
|
e478087260 | ||
|
|
b6fb4f3cae | ||
|
|
61566431cc | ||
|
|
d3baf0ef7e | ||
|
|
4c3fe8ef7b | ||
|
|
c164b5724f | ||
|
|
bc9cb2d7e7 | ||
|
|
5669a2d960 | ||
|
|
50aea0cc64 | ||
|
|
c67f1c3653 | ||
|
|
78f44c8f8c | ||
|
|
45ee0ef102 | ||
|
|
c4da663533 | ||
|
|
69b5901278 | ||
|
|
99fb4c2dad | ||
|
|
25b6a80cb5 | ||
|
|
b334961f75 | ||
|
|
aa95a76253 | ||
|
|
85bf595d2d | ||
|
|
2f15e40a77 | ||
|
|
b784b51cf3 | ||
|
|
f30baa2a1c | ||
|
|
dfab3d50cd | ||
|
|
9daaec4ec4 | ||
|
|
b7effe1e01 | ||
|
|
2a68e7aafb | ||
|
|
3355190a17 | ||
|
|
6ebbc6ae0c | ||
|
|
7248951410 | ||
|
|
2966cee6b0 | ||
|
|
d8735d950e | ||
|
|
48abf73d1a | ||
|
|
11c5a7ce52 | ||
|
|
b6876c50bf | ||
|
|
6d7be8bd6a | ||
|
|
b451d05d10 | ||
|
|
f139588f89 | ||
|
|
ac5446f877 | ||
|
|
adde6c3a29 | ||
|
|
fe5f81e52e | ||
|
|
796683a133 | ||
|
|
eb8f497e9f | ||
|
|
32a838d2e2 | ||
|
|
1b777cd760 | ||
|
|
799fc4ac3c | ||
|
|
d855315db8 | ||
|
|
9bc2cd741c | ||
|
|
673f7fe75f | ||
|
|
da2b42d7fe | ||
|
|
8b4e259421 | ||
|
|
37cb11df8c | ||
|
|
ad70e8763b | ||
|
|
64db3fa050 | ||
|
|
43f62cf7d0 | ||
|
|
dc7b456afa | ||
|
|
c1c1588939 | ||
|
|
abb426a717 | ||
|
|
eae25459f4 | ||
|
|
2a5c089dbe | ||
|
|
55fa5cadcb | ||
|
|
644f59f9dd | ||
|
|
8b01077d44 | ||
|
|
627a5b2d5e | ||
|
|
72011fa6ed | ||
|
|
41b837b398 | ||
|
|
04c00385b9 | ||
|
|
9626ab9c71 | ||
|
|
3de6eeb474 | ||
|
|
af27115f68 | ||
|
|
d8ebfc8023 | ||
|
|
41d4850ab4 | ||
|
|
0dfbceb047 | ||
|
|
cab5253923 | ||
|
|
d89dc347a2 | ||
|
|
66dc0a6396 | ||
|
|
fb8e2976dc | ||
|
|
fd7b517448 | ||
|
|
262f1dc5c5 | ||
|
|
7ec674d33b | ||
|
|
b07d2e109f | ||
|
|
92e1b40361 | ||
|
|
d0ee19a2c2 | ||
|
|
82076ea767 | ||
|
|
5f37817a5c | ||
|
|
148b930182 | ||
|
|
0385072e06 | ||
|
|
1736d23ee7 | ||
|
|
66a975f659 | ||
|
|
6478a8f47d | ||
|
|
fdd86ceb03 | ||
|
|
7584dffc4b | ||
|
|
b400e06473 | ||
|
|
22c3316109 | ||
|
|
a27852de0a | ||
|
|
f80a0d72b3 | ||
|
|
3e8b2735bb | ||
|
|
5065bcbf0a | ||
|
|
7e1cdefd98 | ||
|
|
4199a2506d | ||
|
|
4aa66c7c9b | ||
|
|
afe46a17a0 | ||
|
|
81d700d146 | ||
|
|
d5e9c14b0d | ||
|
|
c17613669c | ||
|
|
cdfd38bd4b | ||
|
|
693e86d763 | ||
|
|
6d10ef2821 | ||
|
|
9fab4742c9 | ||
|
|
f3a46a9be4 | ||
|
|
e460e9034c | ||
|
|
5c8defb940 | ||
|
|
88b851a423 | ||
|
|
94d798ccce | ||
|
|
07998c32ed | ||
|
|
04d12d9524 | ||
|
|
875aac5db0 | ||
|
|
216e8d68ed | ||
|
|
c1b420e480 | ||
|
|
fbb9038651 | ||
|
|
74640d4e83 | ||
|
|
3b8de6a90b | ||
|
|
a6830f341a | ||
|
|
548d26f884 | ||
|
|
9b67a2b1dd | ||
|
|
f952b5006b | ||
|
|
56b4ef7f2b | ||
|
|
1d3963539f | ||
|
|
c4be7fabac | ||
|
|
167e281cc8 | ||
|
|
c6ecc0c786 | ||
|
|
f38dec07ea | ||
|
|
7332993ff3 | ||
|
|
909949c68d | ||
|
|
5a6a1020a1 | ||
|
|
7472002cf3 | ||
|
|
7bd6a2d89b | ||
|
|
3f7dac63b9 | ||
|
|
3d28b2bb99 | ||
|
|
63688f81b4 | ||
|
|
14ac6542e3 | ||
|
|
5739f4e59f | ||
|
|
cae51ce67a | ||
|
|
8e622189ca | ||
|
|
c1c830fc32 | ||
|
|
cf00ebb9d4 | ||
|
|
b05ee63cb2 | ||
|
|
40667bc811 | ||
|
|
4b3d1112d1 | ||
|
|
70edef561f | ||
|
|
e430ade7bd | ||
|
|
519b132355 | ||
|
|
b08dc70654 | ||
|
|
a1d027e4a1 | ||
|
|
defa37c106 | ||
|
|
1514ab3cb5 | ||
|
|
ca25915a38 | ||
|
|
ee2b5adcf9 | ||
|
|
2ef5d00d9b | ||
|
|
bac3dde945 | ||
|
|
1600dc38a5 | ||
|
|
0c8dd8b6e4 | ||
|
|
3217533594 | ||
|
|
0591f13cc2 | ||
|
|
ee2f14576b | ||
|
|
5115fdd48c | ||
|
|
e006b19773 | ||
|
|
07e73efa40 | ||
|
|
2a45a10854 | ||
|
|
e052aec7fb | ||
|
|
cf4d743854 | ||
|
|
3c866c645b | ||
|
|
4a5d689af7 | ||
|
|
6f1548e26a | ||
|
|
764ce2f8e2 | ||
|
|
717c098503 | ||
|
|
2c9a5c4f7e | ||
|
|
34af8d18d0 | ||
|
|
0527bcd98e | ||
|
|
ea530e604c | ||
|
|
97d137b94b | ||
|
|
1ceffbe291 | ||
|
|
684d6752f9 | ||
|
|
76cea02513 | ||
|
|
308b78e96c | ||
|
|
2d787c3fdf | ||
|
|
202c43f46e | ||
|
|
e7be457a56 | ||
|
|
9bf49b7574 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Default owner, including backend and SDK areas without a specialist below.
|
||||
# The last matching rule takes precedence.
|
||||
* @Salazareo
|
||||
|
||||
# GUI
|
||||
/src/gui/ @jelveh @Salazareo
|
||||
|
||||
# Documentation, developer site, and CLI
|
||||
/doc/ @reynaldichernando
|
||||
/src/docs/ @reynaldichernando
|
||||
/src/dev-center/ @reynaldichernando
|
||||
/src/cli/ @reynaldichernando
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland
|
||||
/src/worker/ @ProgrammerIn-wonderland
|
||||
/src/worker-types/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
|
||||
|
||||
# Backend: sharing, permissions, and teams
|
||||
/src/backend/controllers/share/ @jfcastro92
|
||||
/src/backend/controllers/team/ @jfcastro92
|
||||
/src/backend/services/share/ @jfcastro92
|
||||
/src/backend/services/permission/ @jfcastro92
|
||||
/src/backend/services/acl/ @jfcastro92
|
||||
/src/backend/services/team/ @jfcastro92
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92
|
||||
/src/backend/stores/share/ @jfcastro92
|
||||
/src/backend/stores/permission/ @jfcastro92
|
||||
/src/backend/stores/group/ @jfcastro92
|
||||
/src/backend/stores/team/ @jfcastro92
|
||||
|
||||
# SDK: AI, hosting, workers, and networking
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
|
||||
|
||||
# SDK: sharing and permissions
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
|
||||
@@ -0,0 +1,20 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -17,10 +17,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Runs on backend and extension changes. Tests are co-located with the
|
||||
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
|
||||
# changes match the same globs. puter.js SDK changes are covered by
|
||||
# puterjs-tests.yaml instead.
|
||||
# Always triggers so it can be a required PR check; the `changes` job below
|
||||
# decides whether backend/extension paths actually changed (test-only changes
|
||||
# match the same globs, since tests are co-located with the code), and every
|
||||
# other job is skipped — not absent — when they didn't, which still satisfies
|
||||
# a required check. puter.js SDK changes are covered by puterjs-tests.yaml.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
# The type-check job below reads these.
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.build.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
- 'src/backend/**'
|
||||
- 'extensions/**'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- 'tsconfig.build.json'
|
||||
- '.github/workflows/backend-tests.yaml'
|
||||
|
||||
# `tsconfig.build.json` builds with `noCheck: true`, so nothing else in CI
|
||||
# runs the type checker and a missing export compiles to `undefined`,
|
||||
# surfacing only when the call is finally reached at runtime. This diffs
|
||||
@@ -35,13 +46,15 @@ jobs:
|
||||
# Its own job rather than a step in `test`: that one runs a base/PR matrix for
|
||||
# coverage comparison, and the check only needs the PR ref, once.
|
||||
typecheck:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -52,7 +65,19 @@ jobs:
|
||||
- name: Type check (new errors only)
|
||||
run: npm run typecheck
|
||||
|
||||
# `needs.changes` gates the steps below, not this job, on purpose: a
|
||||
# job-level `if` on a matrix job skips the whole job as one unexpanded
|
||||
# check ("test (${{ matrix.artifact }})", literally, since there's no
|
||||
# matrix context to fill it in) instead of the two real per-artifact
|
||||
# checks a required rule pins to ("test (base)" / "test (pr)") — so the
|
||||
# required check never gets reported and the PR blocks forever. Letting
|
||||
# the matrix always expand keeps the check names stable either way.
|
||||
test:
|
||||
needs: changes
|
||||
# Overrides the default matrix naming, which would otherwise bake the
|
||||
# base/head branch name (matrix.ref) into the check name — making it a
|
||||
# different string on every PR and impossible to pin as a required check.
|
||||
name: test (${{ matrix.artifact }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -65,49 +90,53 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests with coverage
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
run: npm run test:backend -- --coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
if: needs.changes.outputs.backend == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: backend-coverage-${{ matrix.artifact }}
|
||||
path: src/backend/coverage/
|
||||
retention-days: 14
|
||||
|
||||
report-coverage:
|
||||
needs: test
|
||||
if: always()
|
||||
needs: [changes, test]
|
||||
if: always() && needs.changes.outputs.backend == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage-pr
|
||||
path: src/backend/coverage
|
||||
|
||||
- name: Download base coverage
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: backend-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Dependabot auto-merge
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
automerge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Enable auto-merge for patch updates
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -45,17 +45,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -64,11 +64,11 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sync README to Docker Hub
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
update-release-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: release
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.find-pr.outputs.number == ''
|
||||
with:
|
||||
ref: release
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
name: Puter.js API Tests
|
||||
|
||||
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
|
||||
# so test changes are covered by the same glob — plus the pieces the
|
||||
# runners depend on: the worker preamble, the in-memory test env in
|
||||
# testUtil, and the backend vitest config the API-tests config extends.
|
||||
# Backend and extension changes run backend-tests.yaml instead.
|
||||
# Always triggers so it can be a required PR check; the `changes` job below
|
||||
# decides whether puter.js-relevant paths actually changed (the suites live
|
||||
# in src/puter-js/tests, so test-only changes are covered by the same glob,
|
||||
# plus the pieces the runners depend on: the worker preamble, the in-memory
|
||||
# test env in testUtil, and the backend vitest config the API-tests config
|
||||
# extends), and every other job is skipped — not absent — when they didn't,
|
||||
# which still satisfies a required check. Backend and extension changes run
|
||||
# backend-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
puterjs: ${{ steps.filter.outputs.puterjs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
puterjs:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
# The JSDoc in src/puter-js/src is the source of truth for the SDK's public
|
||||
# types. The declarations shipped to npm are generated from it at build time
|
||||
# and never committed, so what needs guarding is the JSDoc itself: this
|
||||
@@ -32,14 +46,16 @@ jobs:
|
||||
# Its own job rather than a step in `test`: that one builds bundles and
|
||||
# installs a browser, and this needs neither.
|
||||
types:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -51,14 +67,16 @@ jobs:
|
||||
run: npm run check:puterjs:types
|
||||
|
||||
test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
@@ -83,7 +101,18 @@ jobs:
|
||||
# SDK coverage on both refs, reported as a PR comment — same shape as
|
||||
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
|
||||
# (the `test` job above keeps exercising the production build).
|
||||
# `needs.changes` gates the steps below, not this job, on purpose: a
|
||||
# job-level `if` on a matrix job skips the whole job as one unexpanded
|
||||
# check ("coverage (${{ matrix.artifact }})", literally, since there's no
|
||||
# matrix context to fill it in) instead of two real per-artifact checks —
|
||||
# confusing to read, and unusable if this ever gets pinned as a required
|
||||
# check. Letting the matrix always expand keeps the check names stable.
|
||||
coverage:
|
||||
needs: changes
|
||||
# Overrides the default matrix naming, which would otherwise bake the
|
||||
# base/head branch name (matrix.ref) into the check name — making it a
|
||||
# different string on every PR and impossible to pin as a required check.
|
||||
name: coverage (${{ matrix.artifact }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -96,34 +125,39 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright chromium
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The base leg is best-effort — it only enriches the PR comment with
|
||||
# a comparison, and the base ref may predate the coverage script.
|
||||
- name: Run puter.js API tests with coverage
|
||||
if: needs.changes.outputs.puterjs == 'true'
|
||||
continue-on-error: ${{ matrix.artifact == 'base' }}
|
||||
run: npm run test:puterjs:coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
@@ -131,15 +165,15 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
report-coverage:
|
||||
needs: coverage
|
||||
if: always()
|
||||
needs: [changes, coverage]
|
||||
if: always() && needs.changes.outputs.puterjs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
@@ -149,7 +183,7 @@ jobs:
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: puterjs-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
@@ -45,14 +45,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Package Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Create alias tag
|
||||
env:
|
||||
|
||||
@@ -35,6 +35,8 @@ These apply everywhere — backend, puter.js, and GUI.
|
||||
|
||||
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
|
||||
|
||||
**Be concise — a comment is a sentence or two, not a paragraph block.** One or two lines for `//`, a few for JSDoc. Don't narrate the history ("used to swallow the failure, which meant…"), don't argue the design at length, and don't explain each parameter when the signature already does. State the constraint and stop. If the reasoning genuinely needs paragraphs, it belongs in a design doc, not above the function.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
Before opening a PR, scan the diff for:
|
||||
@@ -92,6 +94,7 @@ Follow the same layered structure inside an extension — unless it only needs a
|
||||
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
|
||||
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
|
||||
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
|
||||
- **Test code, not docs.** Never write a test that reads a file under `src/docs/` and asserts on its wording or numbers. Docs are kept in step by the PR (see the limits rule above) and checked in review; a test that greps a markdown page fails on every rewording and verifies nothing about behavior.
|
||||
|
||||
---
|
||||
|
||||
@@ -112,17 +115,15 @@ Declare a shape where it belongs, and reference it with an `import(...)` type fr
|
||||
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
|
||||
|
||||
```js
|
||||
/** @typedef {{ key: string, value: unknown }} KVEntry */
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
```
|
||||
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads _are_ the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
|
||||
|
||||
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
|
||||
|
||||
```js
|
||||
/**
|
||||
* @typedef {{key:string, value: unknown}} KVEntry
|
||||
*/
|
||||
/** @typedef {{ key: string; value: unknown }} KVEntry */
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
|
||||
@@ -113,7 +113,9 @@ This repository, including all its contents, sub-projects, modules, and componen
|
||||
- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md)
|
||||
- [Malay / Bahasa Malaysia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.my.md)
|
||||
- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md)
|
||||
- [Dutch / Nederlands](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.nl.md)
|
||||
- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md)
|
||||
- [Odia / ଓଡ଼ିଆ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.od.md)
|
||||
- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md)
|
||||
- [Punjabi / ਪੰਜਾਬੀ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pa.md)
|
||||
- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md)
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"teams_enabled": false,
|
||||
"max_teams_per_user": 1,
|
||||
"max_seats_per_team": 50,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+29
-1
@@ -273,6 +273,31 @@
|
||||
// "emailBatchSeconds": 90
|
||||
// },
|
||||
|
||||
// ── Teams / teams ──────────────────────────────────────────────
|
||||
// Off by default, and off is the state every existing install stays in.
|
||||
// The tables ship either way and sit inert; this gates whether `/teams`
|
||||
// is registered at all, so with it off the paths do not exist rather than
|
||||
// existing and refusing. It is also the backout: turning it off removes
|
||||
// the feature without touching data.
|
||||
"teams_enabled": false,
|
||||
//
|
||||
// Live teams one user may own. Soft-deleted ones do not count, so
|
||||
// deleting yours frees the slot. Default 1.
|
||||
"max_teams_per_user": 1,
|
||||
//
|
||||
// Seats one team may provision. Each seat is a real Puter account
|
||||
// taking a name from the global username pool, so this is what bounds a
|
||||
// team's blast radius until per-seat billing lands. Lowering it below
|
||||
// a team's current seat count blocks new provisioning and disables
|
||||
// nobody. Default 50.
|
||||
"max_seats_per_team": 50,
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────
|
||||
// How long a notification is kept, in days from creation. Acknowledged or
|
||||
// not, a row past this is swept. Off by default; any value above 0 arms
|
||||
// the sweep.
|
||||
// "notificationRetentionDays": 14,
|
||||
|
||||
// ── Alarms / alerting ───────────────────────────────────────────────
|
||||
// Where system alarms go. Severity is the routing decision — each
|
||||
// transport takes everything at or above its own `minSeverity`:
|
||||
@@ -377,6 +402,10 @@
|
||||
},
|
||||
"zai": { "apiKey": "" },
|
||||
"alibaba": { "apiKey": "" },
|
||||
"hoonify": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://api.hoonify.ai/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
@@ -398,7 +427,6 @@
|
||||
"xai-image-generation": { "apiKey": "" },
|
||||
|
||||
// ─ Video generation ─
|
||||
"openai-video-generation": { "apiKey": "" },
|
||||
"together-video-generation": { "apiKey": "" },
|
||||
"gemini-video-generation": { "apiKey": "" },
|
||||
|
||||
|
||||
+13
-1
@@ -78,6 +78,11 @@ this.clients.alarm.create(alarmId, message, fields, 'critical', {
|
||||
|
||||
The HTTP error handler uses it: its id is route + error signature, so a hot
|
||||
loop of the same crash is one incident with N occurrences instead of N pages.
|
||||
Dedup and Slack's repeat throttle mean a responder sees only the latest
|
||||
occurrence, so the handler attaches the `HttpError`'s `fields` to the alarm as
|
||||
`details` — put what a responder needs (which upstreams failed, and how)
|
||||
there. Everything in `fields` is also returned to the client in the response
|
||||
body, so it has to be safe to show the caller.
|
||||
Reach for it anywhere else only when the id is that specific — otherwise a
|
||||
per-request alarm can flood the pager.
|
||||
|
||||
@@ -121,7 +126,14 @@ config always has the last word.
|
||||
|
||||
### Repeat throttling
|
||||
|
||||
The chat transport won't repost the same alarm id within
|
||||
Two levels, and they stack.
|
||||
|
||||
The client itself backs off before a repeat is reported at all: every
|
||||
occurrence is reported up to a small burst, then one per interval. A fault
|
||||
recurring hundreds of times a second still counts every occurrence, but it
|
||||
does not write a log line or build an alert payload for each one.
|
||||
|
||||
Below that, the chat transport won't repost the same alarm id within
|
||||
`repeatThrottleMs` (default 15 minutes). The first occurrence always posts,
|
||||
and the next one that gets through reports how many piled up in between —
|
||||
so a hot loop reads as one message with a count, not a wall of them.
|
||||
@@ -96,6 +96,18 @@ extension.on('user.signup', (_key, data) => {
|
||||
});
|
||||
```
|
||||
|
||||
The `app.recommended` event lets extensions customize desktop recommendations.
|
||||
Its `appNames` array starts as a fresh copy of the default ordered names on each
|
||||
call. Listeners may mutate or replace it, including setting it to an empty array.
|
||||
Async listeners are awaited before names are resolved to app summaries; names
|
||||
that do not exist are skipped.
|
||||
|
||||
```ts
|
||||
extension.on('app.recommended', (_key, data) => {
|
||||
data.appNames = ['editor', 'camera'];
|
||||
});
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
|
||||
@@ -53,8 +53,9 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
|
||||
#### Middleware and gates
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
|
||||
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
|
||||
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
|
||||
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { extension } from '../src/backend/extensions.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleInstalledApps } from './installedApps.ts';
|
||||
@@ -51,13 +52,14 @@ const seedUser = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const seedApp = async (ownerUserId: number) => {
|
||||
const seedApp = async (ownerUserId: number, overrides = {}) => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
return server.stores.app.create(
|
||||
{
|
||||
name: `iaapp_${slug}`,
|
||||
title: `Installed App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
...overrides,
|
||||
},
|
||||
{ ownerUserId },
|
||||
);
|
||||
@@ -145,6 +147,43 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
// The resize pipeline leaves an http(s) icon column behind, which is
|
||||
// what says the sized PNG is on the icons subdomain.
|
||||
const app = await seedApp(user.id as number, {
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
});
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
const { protocol, static_hosting_domain } = extension.config;
|
||||
expect(list[0].iconCdnUrl).toBe(
|
||||
`${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no subdomain URL for an app with no generated icon', async () => {
|
||||
const user = await seedUser();
|
||||
const app = await seedApp(user.id as number);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list[0].iconCdnUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import {
|
||||
getAppIconCdnUrl,
|
||||
getAppIconUrl,
|
||||
} from '@heyputer/backend/src/util/appIcon.js';
|
||||
|
||||
const clients = extension.import('client');
|
||||
|
||||
@@ -73,6 +76,8 @@ export const handleInstalledApps = async (
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
// Direct subdomain URL for the client to try before iconUrl.
|
||||
iconCdnUrl: getAppIconCdnUrl(app, extension.config),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { configContainer } from '../src/backend/exports.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import { handleWhoami } from './whoami.ts';
|
||||
@@ -118,6 +119,50 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||||
const user = await server.stores.user.create({
|
||||
username: `wuser_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'hashedpw',
|
||||
email: `${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
requires_phone_verification: true,
|
||||
} as never);
|
||||
const prev = configContainer.phone_verification_card_fallback;
|
||||
configContainer.phone_verification_card_fallback = {
|
||||
enabled: true,
|
||||
} as never;
|
||||
try {
|
||||
const before = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), before.res),
|
||||
);
|
||||
// Phone-gated, but the user has attempts left — no offer yet.
|
||||
expect(
|
||||
before.captured.body as Record<string, unknown>,
|
||||
).toMatchObject({ card_fallback_available: false });
|
||||
|
||||
// Exhausting SMS attempts stamps this flag; whoami is then the only
|
||||
// thing that can still tell a reloading GUI about the offer, since
|
||||
// further sends are rejected by the route's own rate limit.
|
||||
await server.stores.kv.set({
|
||||
key: `card-fallback-open:${user.id}`,
|
||||
value: true,
|
||||
});
|
||||
|
||||
const after = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), after.res),
|
||||
);
|
||||
expect(
|
||||
after.captured.body as Record<string, unknown>,
|
||||
).toMatchObject({ card_fallback_available: true });
|
||||
} finally {
|
||||
configContainer.phone_verification_card_fallback = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('only forwards allow-listed feature flags', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
+33
-3
@@ -1,5 +1,7 @@
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
|
||||
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
@@ -113,13 +115,12 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const oidcOnly = user.password === null;
|
||||
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
|
||||
const rawIconSize =
|
||||
typeof req.query?.icon_size === 'string'
|
||||
? Number(req.query.icon_size)
|
||||
: undefined;
|
||||
const iconSize =
|
||||
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
|
||||
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
||||
? rawIconSize
|
||||
: undefined;
|
||||
const noIcons = !iconSize;
|
||||
@@ -155,6 +156,33 @@ export const handleWhoami = async (
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
// The SMS-to-card escape hatch: true once this user is out of SMS send
|
||||
// attempts and may verify a card instead. It has to ship from here
|
||||
// because /send-confirm-phone can no longer say so — by the time the
|
||||
// fallback opens, further sends are rejected by that route's own rate
|
||||
// limit before any handler runs, so a page reload would otherwise lose
|
||||
// an offer that stays valid for 24 hours. Only for user actors, and it
|
||||
// costs no KV read unless the account is actually phone-gated.
|
||||
card_fallback_available: isUser
|
||||
? await isCardFallbackEligible(
|
||||
extension.config,
|
||||
user,
|
||||
async (key) => (await stores.kv.get({ key })).res,
|
||||
{
|
||||
smsConfigured: () =>
|
||||
Boolean(clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const status = { enabled: null as boolean | null };
|
||||
await clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
status,
|
||||
{},
|
||||
);
|
||||
return status.enabled;
|
||||
},
|
||||
},
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
@@ -173,6 +201,7 @@ export const handleWhoami = async (
|
||||
stores,
|
||||
services,
|
||||
apiBaseUrl: String(extension.config.api_base_url ?? ''),
|
||||
config: extension.config,
|
||||
},
|
||||
{ iconSize, noIcons },
|
||||
)
|
||||
@@ -263,7 +292,8 @@ 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;
|
||||
|
||||
Generated
+733
-395
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -76,7 +76,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1028.0",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
|
||||
@@ -375,18 +375,18 @@ describe('AlarmClient alarm registry', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
client.create('hot', `occurrence ${i}`, { i });
|
||||
}
|
||||
|
||||
const alarm = client.get('hot')!;
|
||||
expect(alarm.count).toBe(50);
|
||||
expect(alarm.count).toBe(500);
|
||||
expect(alarm.occurrences).toHaveLength(20);
|
||||
expect(alarm.timestamps).toHaveLength(20);
|
||||
// The window kept is the most recent one, not the oldest.
|
||||
expect(alarm.occurrences[19].message).toBe('occurrence 49');
|
||||
expect(alarm.occurrences[19].message).toBe('occurrence 499');
|
||||
// Trimming history must not rewind what the transports are told.
|
||||
expect(seen[49]).toMatchObject({ repeatCount: 50, isRepeat: true });
|
||||
expect(seen.at(-1)).toMatchObject({ repeatCount: 500, isRepeat: true });
|
||||
});
|
||||
|
||||
it('names anonymous handlers by their registration order', () => {
|
||||
@@ -519,3 +519,53 @@ describe('AlarmClient known-error rules', () => {
|
||||
expect(chat[0].severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient repeat reporting', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
pdEvent.mockClear();
|
||||
});
|
||||
|
||||
it('reports every occurrence up to the burst, then backs off', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 12; i++) client.create('flap', 'same fault');
|
||||
|
||||
expect(seen).toHaveLength(10);
|
||||
expect(seen[9].repeatCount).toBe(10);
|
||||
});
|
||||
|
||||
it('reports again once the interval is reached', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 500; i++) client.create('flap', 'same fault');
|
||||
|
||||
expect(seen).toHaveLength(11);
|
||||
expect(seen[10].repeatCount).toBe(500);
|
||||
});
|
||||
|
||||
it('keeps a fast-repeating fault out of the log after the burst', () => {
|
||||
const client = makeClient();
|
||||
capture(client);
|
||||
const warn = vi.mocked(console.warn);
|
||||
warn.mockClear();
|
||||
|
||||
for (let i = 0; i < 100; i++) client.create('flap', 'same fault');
|
||||
|
||||
// Occurrences 2-10 only; the first is an ACTIVE line on console.error.
|
||||
expect(warn.mock.calls).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('skips the payload entirely when no transport accepts the severity', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client, 'critical');
|
||||
|
||||
client.create('quiet', 'nobody is listening', {}, 'info');
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,15 @@ const FALLBACK_SEVERITY: PagerSeverity = 'critical';
|
||||
* and `count` keeps that.
|
||||
*/
|
||||
const OCCURRENCE_HISTORY_LIMIT = 20;
|
||||
|
||||
// A fast-recurring fault repeats far faster than it is useful to report, and
|
||||
// every report allocates a log line and a payload. Report every occurrence up
|
||||
// to the burst, then one per interval; `count` still tracks all of them.
|
||||
const REPEAT_REPORT_BURST = 10;
|
||||
const REPEAT_REPORT_INTERVAL = 500;
|
||||
|
||||
const shouldReportRepeat = (count: number): boolean =>
|
||||
count <= REPEAT_REPORT_BURST || count % REPEAT_REPORT_INTERVAL === 0;
|
||||
/** Keeps `info` alarms out of the paging system unless config says otherwise. */
|
||||
const DEFAULT_PAGERDUTY_MIN_SEVERITY: PagerSeverity = 'warning';
|
||||
/** Slack's ceiling once a pager exists: chat gets what doesn't page. */
|
||||
@@ -456,6 +465,8 @@ export class AlarmClient extends PuterClient {
|
||||
private handleRepeat(alarm: Alarm): void {
|
||||
this.applyKnownErrors(alarm);
|
||||
|
||||
if (!shouldReportRepeat(alarm.count)) return;
|
||||
|
||||
console.warn(
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.count})`,
|
||||
);
|
||||
@@ -483,6 +494,18 @@ export class AlarmClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
private hasHandlerFor(severity: PagerSeverity): boolean {
|
||||
for (const { minSeverity, maxSeverity } of this.alertHandlers) {
|
||||
if (
|
||||
meetsMinSeverity(severity, minSeverity) &&
|
||||
withinMaxSeverity(severity, maxSeverity)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private dispatchAlert(alarm: Alarm): void {
|
||||
const resolved = this.resolveSeverity(alarm);
|
||||
if (resolved === 'mute') {
|
||||
@@ -494,6 +517,10 @@ export class AlarmClient extends PuterClient {
|
||||
}
|
||||
alarm.severity = resolved;
|
||||
|
||||
// Build nothing for a severity no transport accepts — the payload
|
||||
// (cleaned fields plus the stack, twice) is the expensive part.
|
||||
if (!this.hasHandlerFor(resolved)) return;
|
||||
|
||||
const fieldsClean = cleanFields(alarm.fields);
|
||||
const repeatCount = alarm.count;
|
||||
|
||||
|
||||
@@ -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 = 66;
|
||||
const CURRENT_SCHEMA_VERSION = 79;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -81,6 +81,250 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it('stamps a version whose migration did not run (known off-by-one)', async () => {
|
||||
// Existing behaviour, not an endorsement: the stamp overshoots the
|
||||
// applied work by one entry, so 70 is reported without 0074 running.
|
||||
const partial = await bootClient({ targetVersion: 70 });
|
||||
try {
|
||||
expect(await userVersionOf(partial)).toBe(70);
|
||||
await expect(
|
||||
partial.read(
|
||||
"SELECT 1 FROM pragma_table_info('group') WHERE name = 'handle'",
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
} finally {
|
||||
partial.onServerShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the team columns and indexes from 0076', async () => {
|
||||
const columnsOf = async (table: string) =>
|
||||
(
|
||||
(await client.read(
|
||||
`SELECT name FROM pragma_table_info('${table}')`,
|
||||
)) as { name: string }[]
|
||||
).map((r) => r.name);
|
||||
|
||||
expect(await columnsOf('group')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'kind',
|
||||
'name',
|
||||
'handle',
|
||||
'deleted_at',
|
||||
]),
|
||||
);
|
||||
expect(await columnsOf('jct_user_group')).toContain('org_owned');
|
||||
expect(await columnsOf('user')).toContain('requires_password_change');
|
||||
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
|
||||
['idx_group_handle', 'idx_group_owner', 'idx_jct_user_group_group'],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
'idx_group_handle',
|
||||
'idx_group_owner',
|
||||
'idx_jct_user_group_group',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies the event_subscriptions indexes from 0081', async () => {
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
|
||||
[
|
||||
'idx_event_subscriptions_app_handler',
|
||||
'idx_event_subscriptions_expires',
|
||||
'idx_event_subscriptions_suspended',
|
||||
],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
'idx_event_subscriptions_app_handler',
|
||||
'idx_event_subscriptions_expires',
|
||||
'idx_event_subscriptions_suspended',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps `handle` unique but lets the seeded groups share a NULL one', async () => {
|
||||
const seeded = (await client.read(
|
||||
'SELECT COUNT(*) AS n FROM `group` WHERE `handle` IS NULL',
|
||||
)) as { n: number }[];
|
||||
expect(seeded[0].n).toBeGreaterThan(1);
|
||||
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['taken'],
|
||||
);
|
||||
await expect(
|
||||
client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
|
||||
['taken'],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('treats `handle` case-insensitively, as usernames are treated', async () => {
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
// Without NOCASE this differs per engine: mysql rejects, the others accept.
|
||||
await expect(
|
||||
client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
|
||||
['Design-Team'],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
|
||||
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
|
||||
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
await expect(
|
||||
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
|
||||
'Design-Team',
|
||||
]),
|
||||
).resolves.toEqual([{ handle: 'design-team' }]);
|
||||
});
|
||||
|
||||
it('rejects a duplicate membership pair after 0078', async () => {
|
||||
const [{ user_id, group_id }] = (await client.read(
|
||||
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
|
||||
'(SELECT MIN(`id`) FROM `group`) AS group_id',
|
||||
)) as { user_id: number; group_id: number }[];
|
||||
|
||||
const insert = () =>
|
||||
client.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[user_id, group_id],
|
||||
);
|
||||
|
||||
await insert();
|
||||
// `GroupStore.addUsers` has no conflict clause, so before 0075 this
|
||||
// second call silently doubled every group permission the user reads.
|
||||
await expect(insert()).rejects.toThrow(/UNIQUE/iu);
|
||||
});
|
||||
|
||||
it('applies the audit table and group-share columns from 0079', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('audit_team_membership')",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((r) => r.name)).toEqual([
|
||||
'id',
|
||||
'group_id',
|
||||
'group_id_keep',
|
||||
'user_id',
|
||||
'user_id_keep',
|
||||
'actor_user_id',
|
||||
'action',
|
||||
'reason',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
const shareColumns = (await client.read(
|
||||
"SELECT name FROM pragma_table_info('share')",
|
||||
)) as { name: string }[];
|
||||
expect(shareColumns.map((r) => r.name)).toContain('holder_group_id');
|
||||
|
||||
const indexes = (await client.read(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' " +
|
||||
'AND (name LIKE ? OR name LIKE ?)',
|
||||
['idx_audit_team_membership%', 'idx_share_holder_group%'],
|
||||
)) as { name: string }[];
|
||||
expect(indexes.map((r) => r.name).sort()).toEqual([
|
||||
// The `_fk` three stop a user or group delete scanning the audit table.
|
||||
'idx_audit_team_membership_actor_fk',
|
||||
'idx_audit_team_membership_group',
|
||||
'idx_audit_team_membership_group_fk',
|
||||
'idx_audit_team_membership_user',
|
||||
'idx_audit_team_membership_user_fk',
|
||||
'idx_share_holder_group',
|
||||
'idx_share_holder_group_entry_issuer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an audit row after its group is deleted, blanking only the FK', async () => {
|
||||
// 0043 leaves `PRAGMA foreign_keys = ON`, so this is real behaviour.
|
||||
const [group] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `group`',
|
||||
)) as { id: number }[];
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `audit_team_membership` ' +
|
||||
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
|
||||
'`actor_user_id`, `action`) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[group.id, group.id, user.id, user.id, user.id, 'reset_member_password'],
|
||||
);
|
||||
|
||||
await client.write('DELETE FROM `group` WHERE `id` = ?', [group.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `group_id`, `group_id_keep`, `action` FROM `audit_team_membership`',
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
group_id: null,
|
||||
group_id_keep: group.id,
|
||||
action: 'reset_member_password',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
const groups = (await client.read(
|
||||
'SELECT `id` FROM `group` ORDER BY `id` LIMIT 2',
|
||||
)) as { id: number }[];
|
||||
|
||||
await client.write(
|
||||
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
['11111111-1111-4111-8111-111111111111', 'shared.txt', user.id, 0],
|
||||
);
|
||||
const [entry] = (await client.read(
|
||||
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
|
||||
['11111111-1111-4111-8111-111111111111'],
|
||||
)) as { id: number }[];
|
||||
|
||||
const insertShare = (uid: string, groupId: number) =>
|
||||
client.write(
|
||||
'INSERT INTO `share` ' +
|
||||
'(`uid`, `issuer_user_id`, `recipient_email`, `holder_user_id`, ' +
|
||||
'`holder_group_id`, `fsentry_id`) VALUES (?, ?, ?, NULL, ?, ?)',
|
||||
[uid, user.id, '[email protected]', groupId, entry.id],
|
||||
);
|
||||
|
||||
await insertShare('share-team-a', groups[0].id);
|
||||
// Different group, same file and issuer -- allowed.
|
||||
await insertShare('share-team-b', groups[1].id);
|
||||
// Both have `holder_user_id` NULL, so the user-holder index permitted them.
|
||||
await expect(insertShare('share-team-c', groups[0].id)).rejects.toThrow(
|
||||
/UNIQUE/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
|
||||
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
|
||||
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
|
||||
await client.write(
|
||||
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
|
||||
['design-team'],
|
||||
);
|
||||
await expect(
|
||||
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
|
||||
'Design-Team',
|
||||
]),
|
||||
).resolves.toEqual([{ handle: 'design-team' }]);
|
||||
});
|
||||
|
||||
it('runs the javascript migrations, not just the .sql ones', async () => {
|
||||
// The `system` user only exists because 0025 (a .dbmig.js file) ran
|
||||
// inside the migration VM.
|
||||
@@ -190,6 +434,190 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
});
|
||||
});
|
||||
|
||||
// 0072 rebuilds `notification` for the scope tuple, and picks up the
|
||||
// user_id index and the delete cascade mysql and postgres already had.
|
||||
describe('0072 notification-scope', () => {
|
||||
// Only the backfill re-runs: the rebuild half is once-only by
|
||||
// construction, but mysql and postgres replay their whole file on
|
||||
// every boot, so these statements have to be safe twice.
|
||||
const BACKFILL = readFileSync(
|
||||
new URL(
|
||||
'./migrations/sqlite/0072_notification-scope.sql',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /(^|\n)UPDATE /.test(s));
|
||||
|
||||
const seedUser = async (): Promise<number> => {
|
||||
const name = `notif-${Math.random().toString(36).slice(2, 10)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `user` (`username`, `uuid`) VALUES (?, ?)',
|
||||
[name, `${name}-uuid`],
|
||||
);
|
||||
const [row] = await client.read(
|
||||
'SELECT `id` FROM `user` WHERE `username` = ?',
|
||||
[name],
|
||||
);
|
||||
return Number(row.id);
|
||||
};
|
||||
|
||||
const seedLegacy = async (
|
||||
userId: number,
|
||||
value: Record<string, unknown> | string,
|
||||
): Promise<string> => {
|
||||
const uid = `n-${Math.random().toString(36).slice(2, 12)}`;
|
||||
await client.write(
|
||||
'INSERT INTO `notification` (`uid`, `user_id`, `value`) VALUES (?, ?, ?)',
|
||||
[
|
||||
uid,
|
||||
userId,
|
||||
typeof value === 'string' ? value : JSON.stringify(value),
|
||||
],
|
||||
);
|
||||
return uid;
|
||||
};
|
||||
|
||||
const scopeOf = async (uid: string) => {
|
||||
const [row] = await client.read(
|
||||
'SELECT `type`, `audience`, `app_uid` FROM `notification` WHERE `uid` = ?',
|
||||
[uid],
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
const runBackfill = async () => {
|
||||
for (const stmt of BACKFILL) await client.write(stmt);
|
||||
};
|
||||
|
||||
it('carries every backfill statement', () => {
|
||||
expect(BACKFILL).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('classifies the markers legacy rows actually carried', async () => {
|
||||
const userId = await seedUser();
|
||||
const received = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-with-you',
|
||||
});
|
||||
const claimed = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-before-you-joined',
|
||||
});
|
||||
const deployed = await seedLegacy(userId, {
|
||||
source: 'worker',
|
||||
title: 'Successfully deployed https://x.puter.work',
|
||||
template: 'user-requesting-share',
|
||||
});
|
||||
const failed = await seedLegacy(userId, {
|
||||
source: 'worker',
|
||||
title: 'Failed to deploy x! boom',
|
||||
template: 'user-requesting-share',
|
||||
});
|
||||
|
||||
await runBackfill();
|
||||
|
||||
expect(await scopeOf(received)).toEqual({
|
||||
type: 'share.received',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
expect(await scopeOf(claimed)).toEqual({
|
||||
type: 'share.claimed',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
// The app is unrecoverable — the payload only ever named a worker.
|
||||
expect(await scopeOf(deployed)).toEqual({
|
||||
type: 'app.worker.deployed',
|
||||
audience: 'developer',
|
||||
app_uid: null,
|
||||
});
|
||||
expect(await scopeOf(failed)).toEqual({
|
||||
type: 'app.worker.deployFailed',
|
||||
audience: 'developer',
|
||||
app_uid: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves anything it does not recognise reading as legacy', async () => {
|
||||
const userId = await seedUser();
|
||||
const unknown = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'something-else',
|
||||
});
|
||||
const bare = await seedLegacy(userId, { title: 'hi' });
|
||||
const notJson = await seedLegacy(userId, 'plain text');
|
||||
|
||||
await runBackfill();
|
||||
|
||||
for (const uid of [unknown, bare, notJson]) {
|
||||
expect(await scopeOf(uid)).toEqual({
|
||||
type: '',
|
||||
audience: 'account',
|
||||
app_uid: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reclassify on replay', async () => {
|
||||
const userId = await seedUser();
|
||||
const uid = await seedLegacy(userId, {
|
||||
source: 'sharing',
|
||||
template: 'file-shared-with-you',
|
||||
});
|
||||
|
||||
await runBackfill();
|
||||
// A row already classified as something else must survive a
|
||||
// second pass untouched.
|
||||
await client.write(
|
||||
'UPDATE `notification` SET `audience` = ?, `app_uid` = ? WHERE `uid` = ?',
|
||||
['app-user', 'app-1234', uid],
|
||||
);
|
||||
await runBackfill();
|
||||
|
||||
expect(await scopeOf(uid)).toEqual({
|
||||
type: 'share.received',
|
||||
audience: 'app-user',
|
||||
app_uid: 'app-1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('indexes user_id and the scope tuple', async () => {
|
||||
const rows = await client.read('PRAGMA index_list(`notification`)');
|
||||
const names = rows.map((r) => String(r.name));
|
||||
expect(names).toContain('idx_notification_user_id');
|
||||
expect(names).toContain('idx_notification_scope');
|
||||
});
|
||||
|
||||
it('retires a deleted user rows', async () => {
|
||||
const [{ foreign_keys: enforcing }] = await client.read(
|
||||
'PRAGMA foreign_keys',
|
||||
);
|
||||
expect(enforcing).toBe(1);
|
||||
|
||||
const userId = await seedUser();
|
||||
const uid = await seedLegacy(userId, { title: 'orphan-to-be' });
|
||||
await client.write('DELETE FROM `user` WHERE `id` = ?', [userId]);
|
||||
|
||||
expect(
|
||||
await client.read(
|
||||
'SELECT `uid` FROM `notification` WHERE `uid` = ?',
|
||||
[uid],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
'idx_notification_created_at',
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -223,6 +651,66 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
}
|
||||
});
|
||||
|
||||
it('deduplicates pre-existing membership rows when 0078 applies', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-dedup-'));
|
||||
const path = join(dir, 'puter.sqlite');
|
||||
try {
|
||||
// Stop at 67 so the duplicates exist the way a live database's do:
|
||||
// written before the unique index, not after it.
|
||||
const before = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path, targetVersion: 67 }),
|
||||
);
|
||||
await before.onServerStart();
|
||||
|
||||
const [{ user_id, group_id }] = (await before.read(
|
||||
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
|
||||
'(SELECT MIN(`id`) FROM `group`) AS group_id',
|
||||
)) as { user_id: number; group_id: number }[];
|
||||
|
||||
for (const pair of [
|
||||
[user_id, group_id],
|
||||
[user_id, group_id],
|
||||
[user_id, group_id],
|
||||
[user_id, group_id + 1],
|
||||
]) {
|
||||
await before.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
pair,
|
||||
);
|
||||
}
|
||||
const seeded = (await before.read(
|
||||
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
|
||||
)) as { id: number; group_id: number }[];
|
||||
expect(seeded).toHaveLength(4);
|
||||
before.onServerShutdown();
|
||||
|
||||
const after = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
await after.onServerStart();
|
||||
expect(await userVersionOf(after)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
|
||||
// Three copies collapse to the lowest id; the distinct pair is left
|
||||
// alone. Losing the higher ids costs nothing -- `addUsers` writes
|
||||
// only the two id columns, so `extra` and `metadata` are NULL.
|
||||
await expect(
|
||||
after.read(
|
||||
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
|
||||
),
|
||||
).resolves.toEqual([seeded[0], seeded[3]]);
|
||||
|
||||
await expect(
|
||||
after.write(
|
||||
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
|
||||
[user_id, group_id],
|
||||
),
|
||||
).rejects.toThrow(/UNIQUE/iu);
|
||||
after.onServerShutdown();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stops early at a configured target version', async () => {
|
||||
const partial = await bootClient({ targetVersion: 5 });
|
||||
try {
|
||||
|
||||
@@ -100,6 +100,19 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[63, ['0068_referral-code-unique.sql']],
|
||||
[64, ['0069_user-block.sql']],
|
||||
[65, ['0070_drop-orphaned-default-groups.sql']],
|
||||
[66, ['0071_share_issuer_index.sql']],
|
||||
[67, ['0072_notification-scope.sql']],
|
||||
[68, ['0073_notification-created-at.sql']],
|
||||
[69, ['0074_add_user_home.sql']],
|
||||
[70, ['0075_event-subscriptions.sql']],
|
||||
[71, ['0076_event-handlers.sql']],
|
||||
[72, ['0077_teams.sql']],
|
||||
[73, ['0078_jct-user-group-pair-unique.sql']],
|
||||
[74, ['0079_team-audit-and-group-shares.sql']],
|
||||
[75, ['0080_kv-share-handles.sql']],
|
||||
[76, ['0081_event-subscriptions-indexes.sql']],
|
||||
[77, ['0082_temp-password-expiry.sql']],
|
||||
[78, ['0083_team-directory.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -1060,6 +1060,7 @@ CREATE TABLE IF NOT EXISTS `user` (
|
||||
`signup_server` varchar(255) DEFAULT NULL,
|
||||
`metadata` json DEFAULT (json_object()),
|
||||
`reputation` smallint DEFAULT '100',
|
||||
`home` varchar(64) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uid` (`uuid`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
@@ -1151,6 +1152,7 @@ CALL _puter_add_col('user', 'signup_user_agent', '`signup_user_agent` varchar(51
|
||||
CALL _puter_add_col('user', 'signup_origin', '`signup_origin` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'signup_server', '`signup_server` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'metadata', '`metadata` json DEFAULT (json_object())');
|
||||
CALL _puter_add_col('user', 'home', '`home` varchar(64) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'reputation', '`reputation` smallint DEFAULT ''100''');
|
||||
|
||||
--
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Give `notification` a scope tuple: what the row is (`type`), the app it is
|
||||
-- about (`app_uid`, NULL for platform), and who the recipient is in relation
|
||||
-- to it (`audience`). Attribution lived inside the `value` JSON as two
|
||||
-- free-form markers, `source` and `template`, that could disagree; these
|
||||
-- columns replace them and are what a scoped list can be indexed on.
|
||||
--
|
||||
-- `app_uid` is char(40) to match `apps`.`uid`, which is `app-` plus a uuid.
|
||||
--
|
||||
-- Idempotent: columns go through `_puter_add_col` (mig_1), the index through
|
||||
-- the guarded procedure. There is no per-file applied-state tracking, so every
|
||||
-- statement here tolerates a re-run.
|
||||
|
||||
CALL _puter_add_col('notification', 'app_uid', '`app_uid` char(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
CALL _puter_add_col('notification', 'audience', '`audience` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''account''');
|
||||
CALL _puter_add_col('notification', 'type', '`type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_scope_index()
|
||||
BEGIN
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_scope'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_scope`
|
||||
(`user_id`, `audience`, `app_uid`, `acknowledged`);
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_add_notification_scope_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
|
||||
|
||||
-- Backfill from the markers the payload actually carried. `type = ''` is the
|
||||
-- not-yet-classified state, so re-running matches nothing the first pass
|
||||
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
|
||||
-- and reads as legacy.
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-with-you';
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE `notification`
|
||||
SET `audience` = 'developer',
|
||||
`app_uid` = NULL,
|
||||
`type` = CASE
|
||||
WHEN JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.title')) LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE `type` = ''
|
||||
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.source')) = 'worker';
|
||||
@@ -0,0 +1,42 @@
|
||||
-- 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 `notification`.`created_at`. The retention sweep selects by age alone
|
||||
-- and by nothing else, so no existing index on the table narrows it — every
|
||||
-- pass would otherwise scan the whole table to find the oldest few hundred
|
||||
-- rows.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_26. There is no per-file
|
||||
-- applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_notification_created_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'notification'
|
||||
AND INDEX_NAME = 'idx_notification_created_at'
|
||||
) THEN
|
||||
ALTER TABLE `notification` ADD INDEX `idx_notification_created_at`
|
||||
(`created_at`);
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_add_notification_created_at_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_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/>.
|
||||
|
||||
-- Home region column. Mirrors SQLite migration 0074. Home region this
|
||||
-- account's data belongs in — a node id, not a storage region. NULL for
|
||||
-- accounts that predate the column, which fall back to `signup_server`.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'home', '`home` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. See
|
||||
-- sqlite/0075_event-subscriptions.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_23. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`sub_id` varchar(80) NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`owner_user_id` int unsigned NOT NULL,
|
||||
`holder_user_id` int unsigned NOT NULL,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion. No foreign key: a row outlives the
|
||||
-- app that made it, which is what keeps it revocable afterwards.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci
|
||||
DEFAULT NULL,
|
||||
`subject` varchar(4096) NOT NULL,
|
||||
`anchor_uid` char(36) NOT NULL,
|
||||
`anchor_path` varchar(4096) NOT NULL,
|
||||
`match` varchar(1024) DEFAULT NULL,
|
||||
`delivery` varchar(16) NOT NULL,
|
||||
`ops` varchar(64) DEFAULT NULL,
|
||||
`handler_name` varchar(128) DEFAULT NULL,
|
||||
`targets` json NOT NULL,
|
||||
`context` text,
|
||||
`permission` varchar(32) NOT NULL,
|
||||
`suspended_at` bigint DEFAULT NULL,
|
||||
`suspended_reason` varchar(64) DEFAULT NULL,
|
||||
`expires_at` bigint DEFAULT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_subscriptions_sub_id` (`sub_id`),
|
||||
KEY `idx_event_subscriptions_token` (`token`),
|
||||
KEY `idx_event_subscriptions_holder` (`holder_user_id`, `app_uid`),
|
||||
KEY `idx_event_subscriptions_owner` (`owner_user_id`),
|
||||
CONSTRAINT `fk_event_subscriptions_owner` FOREIGN KEY (`owner_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_event_subscriptions_holder` FOREIGN KEY (`holder_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -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/>.
|
||||
|
||||
-- `event_subscriptions`.`anchor_uid` was sized for a filesystem node uuid.
|
||||
-- A key-value subscription anchors on an app instead, and `apps`.`uid` is
|
||||
-- `char(40)`, so the column has to hold one.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_27. There is no per-file
|
||||
-- applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_widen_event_subscriptions_anchor_uid()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'anchor_uid'
|
||||
AND CHARACTER_MAXIMUM_LENGTH < 40
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
MODIFY COLUMN `anchor_uid` varchar(40) NOT NULL;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_widen_event_subscriptions_anchor_uid();
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
|
||||
-- the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_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,
|
||||
`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,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_event_handlers_app_name` (`app_uid`, `name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Teams. See sqlite/0077_teams.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so every statement must tolerate a re-run:
|
||||
-- columns go through _puter_add_col, indexes through the guarded procedure below.
|
||||
|
||||
CALL _puter_add_col('group', 'kind', '`kind` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL');
|
||||
CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL');
|
||||
CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_team_indexes()
|
||||
BEGIN
|
||||
-- Already case-insensitive: the table is utf8mb4_unicode_ci. NULLs stay distinct.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'group'
|
||||
AND INDEX_NAME = 'idx_group_handle'
|
||||
) THEN
|
||||
ALTER TABLE `group` ADD UNIQUE INDEX `idx_group_handle` (`handle`);
|
||||
END IF;
|
||||
|
||||
-- Composite, unlike the existing single-column `group_id` key.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'jct_user_group'
|
||||
AND INDEX_NAME = 'idx_jct_user_group_group'
|
||||
) THEN
|
||||
ALTER TABLE `jct_user_group`
|
||||
ADD INDEX `idx_jct_user_group_group` (`group_id`, `user_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_team_indexes();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
|
||||
|
||||
-- Both steps sit behind the index check: mysql re-runs this file on every boot, and
|
||||
-- guarding them together stops a rolling deploy deleting and indexing concurrently.
|
||||
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_membership_pair_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'jct_user_group'
|
||||
AND INDEX_NAME = 'idx_jct_user_group_pair'
|
||||
) THEN
|
||||
-- Self-join because mysql refuses `NOT IN (SELECT ... FROM jct_user_group)` (1093).
|
||||
DELETE dup FROM `jct_user_group` dup
|
||||
JOIN `jct_user_group` keep
|
||||
ON dup.`user_id` = keep.`user_id`
|
||||
AND dup.`group_id` = keep.`group_id`
|
||||
AND dup.`id` > keep.`id`;
|
||||
|
||||
ALTER TABLE `jct_user_group`
|
||||
ADD UNIQUE INDEX `idx_jct_user_group_pair` (`user_id`, `group_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_membership_pair_index();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0076. Types follow mysql_mig_1; the guarded procedure follows mysql_mig_22.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int unsigned DEFAULT NULL,
|
||||
`group_id_keep` int unsigned NOT NULL,
|
||||
`user_id` int unsigned DEFAULT NULL,
|
||||
`user_id_keep` int unsigned NOT NULL,
|
||||
`actor_user_id` int unsigned DEFAULT NULL,
|
||||
`action` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_audit_team_membership_group` (`group_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_user` (`user_id_keep`, `id`),
|
||||
KEY `idx_audit_team_membership_group_fk` (`group_id`),
|
||||
KEY `idx_audit_team_membership_user_fk` (`user_id`),
|
||||
KEY `idx_audit_team_membership_actor_fk` (`actor_user_id`),
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
CONSTRAINT `fk_audit_team_membership_group` FOREIGN KEY (`group_id`)
|
||||
REFERENCES `group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_audit_team_membership_actor` FOREIGN KEY (`actor_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CALL _puter_add_col('share', 'holder_group_id', '`holder_group_id` int unsigned DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_group_share_constraints()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD INDEX `idx_share_holder_group` (`holder_group_id`, `id`);
|
||||
END IF;
|
||||
-- Team shares leave `holder_user_id` NULL, so the existing unique index binds none.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND INDEX_NAME = 'idx_share_holder_group_entry_issuer'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_group_entry_issuer`
|
||||
(`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'share'
|
||||
AND CONSTRAINT_NAME = 'share_holder_group_fk'
|
||||
) THEN
|
||||
ALTER TABLE `share` ADD CONSTRAINT `share_holder_group_fk`
|
||||
FOREIGN KEY (`holder_group_id`) REFERENCES `group` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_group_share_constraints();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Opaque names for shared regions of a user's key-value namespace. See
|
||||
-- sqlite/0080_kv-share-handles.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
|
||||
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
|
||||
-- be a no-op.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`handle` varchar(64) NOT NULL,
|
||||
`owner_user_id` int unsigned NOT NULL,
|
||||
`grantee_user_id` int unsigned NOT NULL,
|
||||
-- Matches `apps`.`uid` exactly, charset included, so an equality against
|
||||
-- one never falls back to a conversion. No foreign key: a handle outlives
|
||||
-- the app it was minted against, which is what keeps it revocable.
|
||||
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
|
||||
`key_prefix` varchar(1024) NOT NULL,
|
||||
`permission` varchar(1024) NOT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`revoked_at` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_kv_share_handles_handle` (`handle`),
|
||||
KEY `idx_kv_share_handles_owner` (`owner_user_id`, `id`),
|
||||
CONSTRAINT `fk_kv_share_handles_owner` FOREIGN KEY (`owner_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_kv_share_handles_grantee` FOREIGN KEY (`grantee_user_id`)
|
||||
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- A subscription made through a handle stores the grant string it was
|
||||
-- authorized under rather than an access mode, and a grant string carries a
|
||||
-- user uuid, an app uid and a key prefix. Guarded on the current length, as
|
||||
-- mig_24: replaying a column change on a growing table rebuilds it every boot.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_widen_subscription_permission()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND COLUMN_NAME = 'permission'
|
||||
AND CHARACTER_MAXIMUM_LENGTH < 1024
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions` MODIFY `permission` varchar(1024) NOT NULL;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL _puter_widen_subscription_permission();
|
||||
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
|
||||
@@ -0,0 +1,60 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
-- a handler's own rows, the expiry reaper and the suspended reaper. See
|
||||
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
|
||||
--
|
||||
-- Idempotent: the guarded procedure, as mig_30/mig_34/mig_35. There is no
|
||||
-- per-file applied-state tracking, so a replay has to be a no-op.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_event_subscriptions_indexes()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_app_handler'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_app_handler` (`app_uid`, `handler_name`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_expires'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_expires` (`expires_at`);
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'event_subscriptions'
|
||||
AND INDEX_NAME = 'idx_event_subscriptions_suspended'
|
||||
) THEN
|
||||
ALTER TABLE `event_subscriptions`
|
||||
ADD INDEX `idx_event_subscriptions_suspended` (`suspended_at`, `id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_event_subscriptions_indexes();
|
||||
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
|
||||
|
||||
CALL _puter_add_col('user', 'temp_password_expires_at', '`temp_password_expires_at` bigint DEFAULT NULL');
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0083_team-directory.sql for the column rationale.
|
||||
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
|
||||
|
||||
CALL _puter_add_col('group', 'directory_enabled', '`directory_enabled` tinyint(1) NOT NULL DEFAULT 0');
|
||||
@@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS "user" (
|
||||
signup_origin varchar(255),
|
||||
signup_server varchar(255),
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
reputation smallint DEFAULT 100
|
||||
reputation smallint DEFAULT 100,
|
||||
home varchar(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
|
||||
-- The single-column issuer index (postgres_mig_1) serves the equality but not
|
||||
-- the ordering; this composite lets the issued half of that listing range-scan
|
||||
-- without a sort.
|
||||
CREATE INDEX IF NOT EXISTS idx_share_issuer
|
||||
ON share (issuer_user_id, id);
|
||||
@@ -0,0 +1,55 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
|
||||
-- `app_uid` is varchar(40) — the width of `apps`.`uid` — and varchar rather
|
||||
-- than char for the same reason `uid` is here: blank padding is not what the
|
||||
-- column means.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS and the `type = ''` guard on the backfill.
|
||||
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS app_uid varchar(40);
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS audience varchar(16) NOT NULL DEFAULT 'account';
|
||||
ALTER TABLE notification ADD COLUMN IF NOT EXISTS type varchar(64) NOT NULL DEFAULT '';
|
||||
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_scope
|
||||
ON notification (user_id, audience, app_uid, acknowledged);
|
||||
|
||||
UPDATE notification
|
||||
SET type = 'share.received', audience = 'account', app_uid = NULL
|
||||
WHERE type = ''
|
||||
AND value->>'template' = 'file-shared-with-you';
|
||||
|
||||
UPDATE notification
|
||||
SET type = 'share.claimed', audience = 'account', app_uid = NULL
|
||||
WHERE type = ''
|
||||
AND value->>'template' = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE notification
|
||||
SET audience = 'developer',
|
||||
app_uid = NULL,
|
||||
type = CASE
|
||||
WHEN value->>'title' LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE type = ''
|
||||
AND value->>'source' = 'worker';
|
||||
@@ -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 `notification`.`created_at` for the retention sweep. See
|
||||
-- mysql/mysql_mig_27.sql for the rationale.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_created_at
|
||||
ON notification (created_at);
|
||||
@@ -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/>.
|
||||
|
||||
-- Home region column. Mirrors SQLite migration 0074. Home region this
|
||||
-- account's data belongs in — a node id, not a storage region. NULL for
|
||||
-- accounts that predate the column, which fall back to `signup_server`.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS home varchar(64);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. See
|
||||
-- sqlite/0075_event-subscriptions.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sub_id VARCHAR(80) NOT NULL UNIQUE,
|
||||
token VARCHAR(255) NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
holder_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_uid VARCHAR(40) DEFAULT NULL,
|
||||
subject VARCHAR(4096) NOT NULL,
|
||||
anchor_uid VARCHAR(36) NOT NULL,
|
||||
anchor_path VARCHAR(4096) NOT NULL,
|
||||
"match" VARCHAR(1024) DEFAULT NULL,
|
||||
delivery VARCHAR(16) NOT NULL,
|
||||
ops VARCHAR(64) DEFAULT NULL,
|
||||
handler_name VARCHAR(128) DEFAULT NULL,
|
||||
targets JSONB NOT NULL,
|
||||
context TEXT DEFAULT NULL,
|
||||
permission VARCHAR(32) NOT NULL,
|
||||
suspended_at BIGINT DEFAULT NULL,
|
||||
suspended_reason VARCHAR(64) DEFAULT NULL,
|
||||
expires_at BIGINT DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_token
|
||||
ON event_subscriptions (token);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_holder
|
||||
ON event_subscriptions (holder_user_id, app_uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_owner
|
||||
ON event_subscriptions (owner_user_id);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `event_subscriptions.anchor_uid` was sized for a filesystem node uuid. A
|
||||
-- key-value subscription anchors on an app instead, and an app uid is 40
|
||||
-- characters. See mysql/mysql_mig_30.sql.
|
||||
--
|
||||
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
|
||||
-- on every replay, even widening to the same type. Skipped once the column is
|
||||
-- already wide enough.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT character_maximum_length FROM information_schema.columns
|
||||
WHERE table_name = 'event_subscriptions' AND column_name = 'anchor_uid'
|
||||
) < 40 THEN
|
||||
ALTER TABLE event_subscriptions
|
||||
ALTER COLUMN anchor_uid TYPE VARCHAR(40);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
|
||||
-- the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_handlers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_uid VARCHAR(40) 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
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_handlers_app_name
|
||||
ON event_handlers (app_uid, name);
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Teams. See sqlite/0077_teams.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS kind text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS name text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS handle text;
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at timestamp;
|
||||
|
||||
-- On lower(handle): text is case-sensitive here, unlike MySQL's utf8mb4_unicode_ci.
|
||||
-- Handle lookups must therefore compare lower(handle) to hit this index.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_handle ON "group" (lower(handle));
|
||||
|
||||
-- No idx_group_owner here: idx_group_owner_user_id already covers it.
|
||||
|
||||
ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint;
|
||||
|
||||
-- Composite, unlike the existing single-column idx_jct_user_group_group_id.
|
||||
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group
|
||||
ON jct_user_group (group_id, user_id);
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
|
||||
|
||||
-- Guarded because postgres re-runs every file on each boot; `to_regclass` follows
|
||||
-- search_path, so it resolves under a test schema too.
|
||||
DO $mig15$
|
||||
BEGIN
|
||||
IF to_regclass('idx_jct_user_group_pair') IS NULL THEN
|
||||
DELETE FROM jct_user_group
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM jct_user_group GROUP BY user_id, group_id
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$mig15$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_jct_user_group_pair
|
||||
ON jct_user_group (user_id, group_id);
|
||||
@@ -0,0 +1,57 @@
|
||||
-- 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/>.
|
||||
|
||||
-- See sqlite/0076. Types follow `audit_user_to_group_permissions` in postgres_mig_1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_team_membership (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
group_id_keep integer NOT NULL,
|
||||
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
user_id_keep integer NOT NULL,
|
||||
actor_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
|
||||
action varchar(255) NOT NULL,
|
||||
reason varchar(255),
|
||||
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group
|
||||
ON audit_team_membership (group_id_keep, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user
|
||||
ON audit_team_membership (user_id_keep, id);
|
||||
|
||||
-- SET NULL has to find the child rows; postgres does not index FK columns for you.
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group_fk
|
||||
ON audit_team_membership (group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user_fk
|
||||
ON audit_team_membership (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_actor_fk
|
||||
ON audit_team_membership (actor_user_id);
|
||||
|
||||
ALTER TABLE share ADD COLUMN IF NOT EXISTS holder_group_id integer
|
||||
REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_share_holder_group
|
||||
ON share (holder_group_id, id);
|
||||
|
||||
-- Default NULLS DISTINCT, matching the other engines, so this binds only team shares.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_group_entry_issuer
|
||||
ON share (holder_group_id, fsentry_id, issuer_user_id);
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Opaque names for shared regions of a user's key-value namespace. See
|
||||
-- sqlite/0080_kv-share-handles.sql for the column rationale.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv_share_handles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
handle VARCHAR(64) NOT NULL,
|
||||
owner_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
grantee_user_id INTEGER NOT NULL
|
||||
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
key_prefix VARCHAR(1024) NOT NULL,
|
||||
permission VARCHAR(1024) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
revoked_at BIGINT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_kv_share_handles_handle
|
||||
ON kv_share_handles (handle);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner
|
||||
ON kv_share_handles (owner_user_id, id);
|
||||
|
||||
-- A cascading delete has to find the child rows, and postgres does not index
|
||||
-- FK columns for you.
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_fk
|
||||
ON kv_share_handles (grantee_user_id);
|
||||
|
||||
-- A subscription made through a handle stores the grant string it was
|
||||
-- authorized under rather than an access mode, and a grant string carries a
|
||||
-- user uuid, an app uid and a key prefix.
|
||||
--
|
||||
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
|
||||
-- on every replay, even widening to the same type. Skipped once the column is
|
||||
-- already wide enough.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (
|
||||
SELECT character_maximum_length FROM information_schema.columns
|
||||
WHERE table_name = 'event_subscriptions' AND column_name = 'permission'
|
||||
) < 1024 THEN
|
||||
ALTER TABLE event_subscriptions
|
||||
ALTER COLUMN permission TYPE VARCHAR(1024);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
-- a handler's own rows, the expiry reaper and the suspended reaper. See
|
||||
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_app_handler
|
||||
ON event_subscriptions (app_uid, handler_name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_expires
|
||||
ON event_subscriptions (expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_suspended
|
||||
ON event_subscriptions (suspended_at, id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS temp_password_expires_at bigint DEFAULT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- See sqlite/0083_team-directory.sql for the column rationale.
|
||||
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
|
||||
|
||||
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS directory_enabled 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/>.
|
||||
|
||||
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
|
||||
-- The issuer foreign key created no index here (unlike mysql, where the FK's
|
||||
-- index implicitly ends in the primary key), so without this the issued half
|
||||
-- of that listing walks the whole table.
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_issuer`
|
||||
ON `share` (`issuer_user_id`, `id`);
|
||||
@@ -0,0 +1,104 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
|
||||
--
|
||||
-- Done as the standard 12-step rebuild rather than three ALTERs, because the
|
||||
-- same table is missing two things mysql and postgres have had since the v1
|
||||
-- schema: an index on `user_id`, which every `listByUserId` scans without, and
|
||||
-- the cascade that retires a deleted user's rows. A column add cannot
|
||||
-- introduce a foreign key here.
|
||||
--
|
||||
-- Rows whose user is already gone are dropped rather than carried over: the
|
||||
-- cascade this migration adds is what would have removed them.
|
||||
--
|
||||
-- Wrapped in one transaction: without it, a crash between the `DROP` and the
|
||||
-- `RENAME` below would lose the table outright rather than leave it
|
||||
-- unmigrated. `IF EXISTS`/`IF NOT EXISTS` guard a stale `notification_new`
|
||||
-- from a previous interrupted attempt.
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS `notification_new`;
|
||||
|
||||
CREATE TABLE `notification_new` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"value" JSON NOT NULL,
|
||||
"acknowledged" INTEGER DEFAULT NULL,
|
||||
"shown" INTEGER DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"app_uid" TEXT DEFAULT NULL,
|
||||
"audience" TEXT NOT NULL DEFAULT 'account',
|
||||
"type" TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY("user_id") REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `notification_new` (
|
||||
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
|
||||
FROM `notification`
|
||||
WHERE `user_id` IN (SELECT `id` FROM `user`);
|
||||
|
||||
DROP TABLE IF EXISTS `notification`;
|
||||
|
||||
ALTER TABLE `notification_new` RENAME TO `notification`;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_user_id`
|
||||
ON `notification` (`user_id`);
|
||||
|
||||
-- Serves both the scoped list and the unacknowledged count.
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_scope`
|
||||
ON `notification` (`user_id`, `audience`, `app_uid`, `acknowledged`);
|
||||
|
||||
-- Backfill from the markers the payload actually carried. `type = ''` is the
|
||||
-- not-yet-classified state, so a re-run matches nothing the first pass
|
||||
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
|
||||
-- and reads as legacy. `json_valid` guards rows whose `value` was stored as a
|
||||
-- bare string.
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.template') = 'file-shared-with-you';
|
||||
|
||||
UPDATE `notification`
|
||||
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.template') = 'file-shared-before-you-joined';
|
||||
|
||||
-- Worker rows carried only `source: 'worker'` and a title, so the app they
|
||||
-- belong to is unrecoverable and they stay unattributed. The title prefix is
|
||||
-- the sole surviving signal of which way the deploy went.
|
||||
UPDATE `notification`
|
||||
SET `audience` = 'developer',
|
||||
`app_uid` = NULL,
|
||||
`type` = CASE
|
||||
WHEN json_extract(`value`, '$.title') LIKE 'Successfully deployed %'
|
||||
THEN 'app.worker.deployed'
|
||||
ELSE 'app.worker.deployFailed'
|
||||
END
|
||||
WHERE `type` = ''
|
||||
AND json_valid(`value`)
|
||||
AND json_extract(`value`, '$.source') = 'worker';
|
||||
|
||||
COMMIT;
|
||||
@@ -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/>.
|
||||
|
||||
-- Index `notification`.`created_at` for the retention sweep. See
|
||||
-- mysql/mysql_mig_27.sql for the rationale. A plain index add, so no table
|
||||
-- rebuild.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_notification_created_at`
|
||||
ON `notification` (`created_at`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Home region this account's data belongs in — a node id, not a storage
|
||||
-- region. NULL for every account that predates the column, which falls back to
|
||||
-- `signup_server`. What a name maps to is deployment config, so adding a
|
||||
-- region needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `home` VARCHAR(64) DEFAULT NULL;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Subscriptions that outlive the connection that made them. Session
|
||||
-- subscriptions never reach this table — they are Redis only — so every row
|
||||
-- here is one somebody has to be able to find and revoke later.
|
||||
--
|
||||
-- - `sub_id` : `<appUid>#<uuid>`, or `user#<uuid>` for a row a user
|
||||
-- session created. Unique table-wide; it is what
|
||||
-- `unsubscribe` names.
|
||||
-- - `owner_user_id` : owner of the anchor node. Dispatch only knows whose
|
||||
-- resource changed, so this is the side a shared-folder
|
||||
-- subscription has to be findable from, and the key a
|
||||
-- region's cache is rebuilt under.
|
||||
-- - `holder_user_id` : who subscribed — the delivery target, whose access is
|
||||
-- re-checked, and who the quota counts against.
|
||||
-- - `app_uid` : NULL for a row a user session created. No foreign
|
||||
-- key: a row outlives the app that made it, which is
|
||||
-- what keeps it listable and revocable afterwards.
|
||||
-- - `anchor_*` : the resolved anchor. `match` is a glob relative to
|
||||
-- `anchor_path`, so the path is not decoration — the
|
||||
-- dispatch filter reads it on every event.
|
||||
-- - `ops` : op filter; a comma-separated set, NULL for every op.
|
||||
-- - `targets` : JSON array over socket|worker|push.
|
||||
-- - `context` : plaintext JSON, hard-capped at 4 KB, read only on the
|
||||
-- delivery path and never returned by `list`.
|
||||
-- - `permission` : the ACL mode the subscribe check passed under,
|
||||
-- re-checked per delivery.
|
||||
-- - `suspended_*` : set when a subscription stops delivering without
|
||||
-- being removed; the state machine that drives them
|
||||
-- lands with the delivery classes that need it.
|
||||
--
|
||||
-- `created_at` and the two timestamps are unix seconds, matching
|
||||
-- `app_feedback` and `user_block`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"sub_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"owner_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"holder_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"app_uid" TEXT DEFAULT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"anchor_uid" TEXT NOT NULL,
|
||||
"anchor_path" TEXT NOT NULL,
|
||||
"match" TEXT DEFAULT NULL,
|
||||
"delivery" TEXT NOT NULL,
|
||||
"ops" TEXT DEFAULT NULL,
|
||||
"handler_name" TEXT DEFAULT NULL,
|
||||
"targets" TEXT NOT NULL,
|
||||
"context" TEXT DEFAULT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"suspended_at" INTEGER DEFAULT NULL,
|
||||
"suspended_reason" TEXT DEFAULT NULL,
|
||||
"expires_at" INTEGER DEFAULT NULL,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_subscriptions_sub_id`
|
||||
ON `event_subscriptions` (`sub_id`);
|
||||
|
||||
-- Dispatch reads the table only when a region's cache is cold; this is the
|
||||
-- lookup it falls back to.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_token`
|
||||
ON `event_subscriptions` (`token`);
|
||||
|
||||
-- List, revoke and quota all key here — the index is the scope check rather
|
||||
-- than a filter over a wider read.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_holder`
|
||||
ON `event_subscriptions` (`holder_user_id`, `app_uid`);
|
||||
|
||||
-- Rebuilding one region's cache for one owner, and the sweep that follows it.
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_owner`
|
||||
ON `event_subscriptions` (`owner_user_id`);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Named handlers an app deploys once and its users' subscriptions bind to by
|
||||
-- name. A handler is an addressable object with its own lifecycle: nothing
|
||||
-- triggers by name, and a row here runs only when a subscription bound to it
|
||||
-- has a delivery.
|
||||
--
|
||||
-- - `app_uid` : the namespace. Names are unique per app, and there is no
|
||||
-- foreign key for the same reason `event_subscriptions`
|
||||
-- has none — a row has to stay manageable after the app
|
||||
-- row moves.
|
||||
-- - `name` : the identity a subscription binds to, stable across
|
||||
-- source changes.
|
||||
-- - `source` : the serialized function. Read only by the delivery path
|
||||
-- and never returned by `list`.
|
||||
-- - `source_hash` : change detector and idempotency key. A publish carrying
|
||||
-- the same hash is a no-op, and a subscription sending an
|
||||
-- inline hash binds only when it matches this one.
|
||||
--
|
||||
-- `created_at` / `updated_at` are unix seconds, matching `event_subscriptions`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `event_handlers` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"app_uid" TEXT 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
|
||||
);
|
||||
|
||||
-- The name is the identity, and it is unique inside one app. This index is
|
||||
-- also the lookup a subscribe binding check runs on.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_handlers_app_name`
|
||||
ON `event_handlers` (`app_uid`, `name`);
|
||||
@@ -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/>.
|
||||
|
||||
-- A `group` row with `kind = 'team'` owns accounts and pays for them; seeded
|
||||
-- system groups keep `kind` NULL so a team query never returns them.
|
||||
|
||||
ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `handle` TEXT COLLATE NOCASE DEFAULT NULL;
|
||||
ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL;
|
||||
|
||||
-- NOCASE on both column and index, so lookups match mysql's utf8mb4_unicode_ci.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle`
|
||||
ON `group` (`handle` COLLATE NOCASE);
|
||||
|
||||
-- mysql and postgres already index this column; sqlite does not.
|
||||
CREATE INDEX IF NOT EXISTS `idx_group_owner` ON `group` (`owner_user_id`);
|
||||
|
||||
-- 1 = team-created, 0 = the team owner. Decides who pays, not who may read.
|
||||
ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL;
|
||||
|
||||
-- Covers "list this team's members"; the unique pair index lands in 0072.
|
||||
CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group`
|
||||
ON `jct_user_group` (`group_id`, `user_id`);
|
||||
|
||||
-- Fourth `requires_*` flag; assertVerifiedAccount will enforce it in phase 5.
|
||||
ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `GroupStore.addUsers` had no conflict clause, so a repeat call duplicated the pair.
|
||||
-- `readUserGroupPerms` joins this table on group_id alone, so each duplicate reported
|
||||
-- every group permission an extra time. The delete clears what accumulated.
|
||||
-- Dropping the higher id is lossless: `addUsers` writes only the two id columns, and
|
||||
-- nothing references `jct_user_group.id`.
|
||||
|
||||
DELETE FROM `jct_user_group`
|
||||
WHERE `id` NOT IN (
|
||||
SELECT MIN(`id`) FROM `jct_user_group` GROUP BY `user_id`, `group_id`
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_jct_user_group_pair`
|
||||
ON `jct_user_group` (`user_id`, `group_id`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Insert-only record of what an admin did to an account, plus `share.holder_group_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
-- Nullable FK beside a NOT NULL `_keep`, as `audit_user_to_group_permissions` since 0019.
|
||||
"group_id" INTEGER DEFAULT NULL,
|
||||
"group_id_keep" INTEGER NOT NULL,
|
||||
"user_id" INTEGER DEFAULT NULL,
|
||||
"user_id_keep" INTEGER NOT NULL,
|
||||
"actor_user_id" INTEGER DEFAULT NULL,
|
||||
|
||||
"action" TEXT NOT NULL,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
|
||||
FOREIGN KEY("group_id") REFERENCES "group" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
FOREIGN KEY("actor_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- The admin's view: one team, newest first.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group`
|
||||
ON `audit_team_membership` (`group_id_keep`, `id`);
|
||||
|
||||
-- The member's view; the only place a reset becomes visible to its subject.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user`
|
||||
ON `audit_team_membership` (`user_id_keep`, `id`);
|
||||
|
||||
-- Without these, SET NULL scans the audit table on every user or group delete.
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group_fk`
|
||||
ON `audit_team_membership` (`group_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user_fk`
|
||||
ON `audit_team_membership` (`user_id`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_actor_fk`
|
||||
ON `audit_team_membership` (`actor_user_id`);
|
||||
|
||||
-- Mirrors `holder_user_id` from 0067: a `share` row is a listing entry, not the grant.
|
||||
ALTER TABLE `share` ADD COLUMN `holder_group_id` INTEGER DEFAULT NULL
|
||||
REFERENCES `group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_share_holder_group`
|
||||
ON `share` (`holder_group_id`, `id`);
|
||||
|
||||
-- Team shares leave `holder_user_id` NULL, and NULLs are distinct, so the existing
|
||||
-- unique index binds none of them.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_group_entry_issuer`
|
||||
ON `share` (`holder_group_id`, `fsentry_id`, `issuer_user_id`);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- An opaque name for a region of one user's key-value namespace that somebody
|
||||
-- else may watch. Key-value subjects carry no owner component, so without this
|
||||
-- a shared key is unaddressable: the handle is what a filesystem node uid
|
||||
-- already is, an owner-independent name for a shared root.
|
||||
--
|
||||
-- - `handle` : `kvh-<uuid>`, deliberately shaped unlike an app uid so
|
||||
-- the subject parser can tell which slot it is in. It is
|
||||
-- the only form the grantee ever sees, and it names
|
||||
-- neither the owner's username nor their uuid.
|
||||
-- - `permission` : the user-to-user grant this handle mirrors. Stored
|
||||
-- rather than derived: it is the key revocation settling
|
||||
-- matches subscriptions on, and deriving it would tie the
|
||||
-- settle path to a lookup per row.
|
||||
-- - `key_prefix` : the granted root, always ending on the key delimiter,
|
||||
-- so `key_prefix + relative` is a whole key.
|
||||
-- - `revoked_at` : set rather than deleted. A revoked handle stays visible
|
||||
-- to its owner, which is the only record of what was
|
||||
-- shared and when it stopped.
|
||||
--
|
||||
-- `created_at` / `revoked_at` are unix seconds, matching `event_subscriptions`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"handle" TEXT NOT NULL,
|
||||
"owner_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"grantee_user_id" INTEGER NOT NULL
|
||||
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"key_prefix" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"created_at" INTEGER NOT NULL, -- unix seconds
|
||||
"revoked_at" INTEGER DEFAULT NULL
|
||||
);
|
||||
|
||||
-- Resolving a subject is a lookup by handle, on the subscribe path.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_kv_share_handles_handle`
|
||||
ON `kv_share_handles` (`handle`);
|
||||
|
||||
-- The owner's audit listing, and the scope check behind revoke.
|
||||
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner`
|
||||
ON `kv_share_handles` (`owner_user_id`, `id`);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `event_subscriptions` grew three hot queries with no index behind them:
|
||||
--
|
||||
-- - `(app_uid, handler_name)` : a handler's own rows (`listByHandler`) —
|
||||
-- suspending/resuming everything one
|
||||
-- publish or removal touches.
|
||||
-- - `(expires_at)` : the expiry reaper's sweep.
|
||||
-- - `(suspended_at, id)` : the suspended reaper's sweep. The range on
|
||||
-- `suspended_at` is the win; `id` only keeps
|
||||
-- the reaper's keyset page off the table.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_app_handler`
|
||||
ON `event_subscriptions` (`app_uid`, `handler_name`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_expires`
|
||||
ON `event_subscriptions` (`expires_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_suspended`
|
||||
ON `event_subscriptions` (`suspended_at`, `id`);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Unix seconds after which an admin-issued temporary password stops
|
||||
-- authenticating, so an unused reset dies instead of becoming a standing
|
||||
-- credential. NULL for every password the account chose itself.
|
||||
ALTER TABLE `user` ADD COLUMN `temp_password_expires_at` INTEGER DEFAULT 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/>.
|
||||
|
||||
-- Whether apps acting for a member may read this team's member list.
|
||||
-- Members can already enumerate each other through `/teams/:uid/members`,
|
||||
-- which requires a user actor; this opens the same names to an app actor,
|
||||
-- so it is off until the team owner turns it on.
|
||||
ALTER TABLE `group` ADD COLUMN `directory_enabled` INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -441,6 +441,7 @@ export class DDBClient extends PuterClient {
|
||||
index = '',
|
||||
consistentRead = false,
|
||||
options?: {
|
||||
scanIndexForward?: boolean;
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
filter?: {
|
||||
@@ -497,6 +498,9 @@ export class DDBClient extends PuterClient {
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
...(options?.scanIndexForward !== undefined
|
||||
? { ScanIndexForward: options.scanIndexForward }
|
||||
: {}),
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
@@ -511,6 +515,7 @@ export class DDBClient extends PuterClient {
|
||||
expression: string,
|
||||
expressionValues?: Record<string, unknown>,
|
||||
expressionNames?: Record<string, string>,
|
||||
options?: { condition?: string },
|
||||
) {
|
||||
const hasValues =
|
||||
!!expressionValues && Object.keys(expressionValues).length > 0;
|
||||
@@ -531,6 +536,9 @@ export class DDBClient extends PuterClient {
|
||||
...(hasNames
|
||||
? { ExpressionAttributeNames: expressionNames }
|
||||
: {}),
|
||||
...(options?.condition
|
||||
? { ConditionExpression: options.condition }
|
||||
: {}),
|
||||
ReturnValues: 'ALL_NEW',
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { digestLines } from '../../services/share/shareNotifyTitle';
|
||||
import { EmailClient } from './EmailClient';
|
||||
|
||||
const FROM = '"Puter" <[email protected]>';
|
||||
@@ -262,14 +263,25 @@ describe('EmailClient — share notification templates', () => {
|
||||
return captured;
|
||||
};
|
||||
|
||||
// Built by the producer rather than hand-shaped, so a change to the digest
|
||||
// wording can't leave these fixtures describing a shape it no longer emits.
|
||||
const HOLDER = {
|
||||
recipient: 'alice',
|
||||
subject_line: 'bob shared notes.md with you',
|
||||
shares: [
|
||||
{ sender: 'bob', what: 'notes.md' },
|
||||
{ sender: 'carol', what: '3 items — a.txt, b.txt, +1 more' },
|
||||
],
|
||||
link: 'https://puter.test',
|
||||
shares: digestLines([
|
||||
{
|
||||
username: 'bob',
|
||||
count: 1,
|
||||
items: [{ name: 'notes.md', link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md' }],
|
||||
},
|
||||
{
|
||||
username: 'carol',
|
||||
count: 3,
|
||||
items: [{ name: 'a.txt' }, { name: 'b.txt' }],
|
||||
},
|
||||
]),
|
||||
link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md',
|
||||
origin: 'https://puter.test',
|
||||
unsubscribe_uuid: null,
|
||||
};
|
||||
|
||||
@@ -293,13 +305,41 @@ describe('EmailClient — share notification templates', () => {
|
||||
it('escapes item names in the html and leaves them raw in the text', async () => {
|
||||
const { html, text } = await renderShare('file_shared_with_you', {
|
||||
...HOLDER,
|
||||
shares: [{ sender: 'bob', what: 'r&d "notes".md' }],
|
||||
shares: digestLines([
|
||||
{
|
||||
username: 'bob',
|
||||
count: 1,
|
||||
items: [{ name: 'r&d "notes".md' }],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(html).toContain('r&d "notes".md');
|
||||
expect(text).toContain('r&d "notes".md');
|
||||
});
|
||||
|
||||
// The name links to the item. The URL is ours, built from the origin and one
|
||||
// encoded path, so it stays literal — an entity-escaped `=` would still
|
||||
// resolve, but the text part has no parser to undo it.
|
||||
it('links a named item to itself in both parts', async () => {
|
||||
const link = 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md';
|
||||
const { html, text } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
expect(html).toContain(`<a href="${link}"`);
|
||||
expect(html).toContain(`>notes.md</a>`);
|
||||
expect(text).toContain(link);
|
||||
expect(html).not.toContain('=');
|
||||
});
|
||||
|
||||
// An item with no link — an invite, with no account to route to yet —
|
||||
// renders as the plain name it always did.
|
||||
it('leaves an unlinked item as plain text', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
expect(html).toContain('a.txt');
|
||||
expect(html).not.toContain('>a.txt</a>');
|
||||
});
|
||||
|
||||
it('renders as a responsive single column with no remote assets', async () => {
|
||||
const { html } = await renderShare('file_shared_with_you', HOLDER);
|
||||
|
||||
@@ -325,15 +365,21 @@ describe('EmailClient — share notification templates', () => {
|
||||
expect(preheader).toBeLessThan(html.indexOf('Hi alice,'));
|
||||
});
|
||||
|
||||
it('points the call to action at the app and nowhere else', async () => {
|
||||
const { html, text } = await renderShare(
|
||||
'file_shared_with_you',
|
||||
HOLDER,
|
||||
);
|
||||
// The button carries every item in the mail, so it is a query string with
|
||||
// `&` and `=` in it — which must reach the client as written, in both parts.
|
||||
it('points the call to action at Shared with every item picked out', async () => {
|
||||
const link =
|
||||
'https://puter.test/?shared=%2Fbob%2Fu1%2Fa.txt&shared=%2Fbob%2Fu2%2Fb.txt';
|
||||
const { html, text } = await renderShare('file_shared_with_you', {
|
||||
...HOLDER,
|
||||
link,
|
||||
});
|
||||
|
||||
expect(html).toContain('href="https://puter.test"');
|
||||
expect(html).toContain(`href="${link}"`);
|
||||
expect(html).toContain('>Open Puter</a>');
|
||||
expect(text).toContain('Open Puter: https://puter.test');
|
||||
expect(text).toContain(`Open Puter: ${link}`);
|
||||
expect(html).not.toContain('=');
|
||||
expect(html).not.toContain('&shared');
|
||||
});
|
||||
|
||||
it('separates senders without a rule above the first', async () => {
|
||||
@@ -364,7 +410,9 @@ describe('EmailClient — share notification templates', () => {
|
||||
const { html, text } = await renderShare('file_shared_invite', {
|
||||
email: '[email protected]',
|
||||
subject_line: 'bob shared notes.md with you on Puter',
|
||||
shares: [{ sender: 'bob', what: 'notes.md' }],
|
||||
shares: digestLines([
|
||||
{ username: 'bob', count: 1, items: [{ name: 'notes.md' }] },
|
||||
]),
|
||||
link: 'https://puter.test',
|
||||
});
|
||||
|
||||
|
||||
@@ -28,10 +28,15 @@ import {
|
||||
type EmailTemplateName,
|
||||
} from './templates';
|
||||
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
/**
|
||||
* Attachment shape passed through to the underlying transport. Give either
|
||||
* `content` (held in memory) or `path` (a local file the transport streams on
|
||||
* its own, re-read for every message that carries it).
|
||||
*/
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
content?: Buffer | string;
|
||||
path?: string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
@@ -161,19 +166,23 @@ 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).
|
||||
*
|
||||
* Returns the transport's send result, or `null` when none is configured —
|
||||
* {@link sendRaw} drops the message instead of throwing. A caller that
|
||||
* treats undelivered mail as a failure must check for `null` too.
|
||||
*/
|
||||
async send<T extends EmailTemplateName>(
|
||||
to: string,
|
||||
template: T,
|
||||
values: Record<string, unknown> = {},
|
||||
options: { replyTo?: string } = {},
|
||||
): Promise<void> {
|
||||
) {
|
||||
const compiled = this.compiledTemplates[template];
|
||||
if (!compiled) {
|
||||
throw new Error(`Unknown email template: ${template}`);
|
||||
}
|
||||
|
||||
await this.sendRaw({
|
||||
return await this.sendRaw({
|
||||
from: this.defaultFrom(),
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
|
||||
@@ -169,14 +169,22 @@ const headingRow = (text: string): string => `
|
||||
* One row per sender, hairline-separated. The wording comes pre-composed from
|
||||
* the digest (`digestLines`), so the list stays a list however many senders and
|
||||
* items fold into it.
|
||||
*
|
||||
* Each named item links to itself where it has one; `lead`/`trail` are the
|
||||
* wording around the names. An unlinked item renders as the plain name.
|
||||
*
|
||||
* `link` is triple-braced because we build it, so `?` and `=` stay literal;
|
||||
* `name` is the owner's text and stays escaped.
|
||||
*/
|
||||
const SHARE_ITEM_LIST = `{{this.lead}}{{#each this.items}}{{#unless @first}}, {{/unless}}{{#if this.link}}<a href="{{{this.link}}}" style="color: ${ACCENT}; text-decoration: underline;">{{this.name}}</a>{{else}}{{this.name}}{{/if}}{{/each}}{{this.trail}}`;
|
||||
|
||||
const SHARE_LIST_ROW = `
|
||||
<tr>
|
||||
<td class="panel" style="padding: 6px 18px; background-color: ${PANEL_BG}; border: 1px solid ${PANEL_BORDER}; border-radius: 10px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared {{this.what}}</td>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
@@ -193,6 +201,9 @@ const textRow = (text: string, padding = '18px 0 0'): string => `
|
||||
* The call to action. Padding sits on the cell and the color on `bgcolor` so
|
||||
* Outlook still draws a real button (square-cornered, which is fine); the table
|
||||
* goes full width under 600px so the tap target spans the card.
|
||||
*
|
||||
* `link` is triple-braced for the same reason as an item's: we build it, and a
|
||||
* deep link's `=` and `&` should read the same in the html as in the text.
|
||||
*/
|
||||
const buttonRow = (label: string): string => `
|
||||
<tr>
|
||||
@@ -200,7 +211,7 @@ const buttonRow = (label: string): string => `
|
||||
<table role="presentation" class="btn" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="${ACCENT}" style="background-color: ${ACCENT}; border-radius: 10px; padding: 14px 26px;">
|
||||
<a href="{{link}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
|
||||
<a href="{{{link}}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -333,6 +344,64 @@ [email protected] immediately.
|
||||
<p>A password recovery request was issued for your account, please follow the link below to reset your password:</p>
|
||||
<p><a href="{{link}}">{{link}}</a></p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_created: {
|
||||
subject: 'Your {{team_name}} account on Puter',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
|
||||
They will send you a temporary password separately; you will be asked to
|
||||
choose your own the first time you sign in.</p>
|
||||
<p>What this means:</p>
|
||||
<ul>
|
||||
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
|
||||
<li>{{team_name}} <b>cannot</b> see your files, apps or data.</li>
|
||||
<li>{{team_name}} <b>can</b> reset your password, which would let them sign in
|
||||
as you. Choose your own password promptly.</li>
|
||||
</ul>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_account_disabled: {
|
||||
subject: 'Your {{team_name}} account has been disabled',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has disabled your Puter account <b>{{username}}</b>. You can no
|
||||
longer sign in to it.</p>
|
||||
<p>Nothing has been deleted. Your files, apps and data are as you left them, and
|
||||
{{team_name}} can re-enable the account at any time. If you believe this is a
|
||||
mistake, ask them.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_closed: {
|
||||
subject: '{{team_name}} has been closed',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>{{team_name}} has closed its Puter team, and your account
|
||||
<b>{{username}}</b> has been disabled along with it. You can no longer sign in to
|
||||
it.</p>
|
||||
<p>Nothing has been deleted. Your files and data are still there; closing the
|
||||
team does not destroy the accounts it created, and each one would have to be
|
||||
deleted on its own request.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
team_password_reset: {
|
||||
subject: 'Your {{team_name}} account password was reset',
|
||||
html: `
|
||||
<p>Hi there,</p>
|
||||
<p>An administrator of {{team_name}} reset the password on your Puter account
|
||||
<b>{{username}}</b>. They will send you a temporary password separately, and you
|
||||
will be asked to choose your own the next time you sign in.</p>
|
||||
<p>If you did not expect this, the record of who reset it and when is in your
|
||||
team activity, along with every sign-in to your account.</p>
|
||||
<p>Sincerely,</p>
|
||||
<p>Puter</p>
|
||||
`,
|
||||
},
|
||||
@@ -362,7 +431,9 @@ immediately</p>
|
||||
/**
|
||||
* A digest: shares to one recipient are held briefly and merged, so
|
||||
* `shares` may carry several senders. The subject is composed by the
|
||||
* service (see `digestSubject`), which owns the grouped wording.
|
||||
* service (see `digestSubject`), which owns the grouped wording. `link`
|
||||
* opens Shared with every item highlighted; `origin` is the bare site, for
|
||||
* the unsubscribe link.
|
||||
*/
|
||||
file_shared_with_you: {
|
||||
subject: '{{subject_line}}',
|
||||
@@ -378,7 +449,7 @@ immediately</p>
|
||||
'24px 0 0',
|
||||
),
|
||||
footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}}
|
||||
<br /><a href="{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
|
||||
<br /><a href="{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
|
||||
}),
|
||||
text: `
|
||||
Hi{{#if recipient}} {{recipient}}{{/if}},
|
||||
@@ -386,7 +457,8 @@ immediately</p>
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
{{/each}}
|
||||
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
|
||||
{{/if}}{{/each}}{{/each}}
|
||||
|
||||
Open Puter: {{link}}
|
||||
|
||||
@@ -395,7 +467,7 @@ immediately</p>
|
||||
--
|
||||
You're receiving this because someone shared with your Puter account.
|
||||
{{#if unsubscribe_uuid}}Unsubscribe from notification emails:
|
||||
{{link}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
|
||||
{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
|
||||
`,
|
||||
},
|
||||
// The only way to reach someone with no account. Same digest shape.
|
||||
|
||||
@@ -32,6 +32,27 @@ type GuiEvent<R = Record<string, unknown>> = {
|
||||
response: R;
|
||||
};
|
||||
|
||||
// The entry arrives under several aliases, for handlers of any vintage.
|
||||
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };
|
||||
|
||||
/**
|
||||
* Who pays, and for which team. Deliberately no payment identity: the payer's
|
||||
* row is alive at emit time, so a consumer resolves it when it acts -- a
|
||||
* snapshot taken here would be stale, and two events racing on it would each
|
||||
* create their own customer.
|
||||
*/
|
||||
export type TeamBillingContext = {
|
||||
team_uid: string;
|
||||
owner_user_id: number;
|
||||
};
|
||||
|
||||
/** A team event about one seat. */
|
||||
export type TeamBillingEvent = TeamBillingContext & {
|
||||
user_id: number;
|
||||
user_uuid: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
|
||||
* events declare the payload here by declaration merging, so both the emitter
|
||||
@@ -50,6 +71,15 @@ type GuiEvent<R = Record<string, unknown>> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IExtensionEventMap {}
|
||||
|
||||
/**
|
||||
* What a KV mutation did to a key. `expire` is a lifetime change rather than a
|
||||
* value change, which is why it is not folded into `set`.
|
||||
*/
|
||||
export type KvOp = 'set' | 'del' | 'expire';
|
||||
|
||||
/** A flush empties a whole namespace, so it is never reported per key. */
|
||||
export type KvMutation = KvOp | 'flush';
|
||||
|
||||
export type EventMap = {
|
||||
// ---- Server lifecycle ----
|
||||
serverStart: Record<string, never>;
|
||||
@@ -107,6 +137,8 @@ export type EventMap = {
|
||||
};
|
||||
|
||||
// ---- Apps ----
|
||||
/** Awaited before lookup; listeners may mutate or replace appNames. */
|
||||
'app.recommended': { appNames: string[] };
|
||||
'app.changed': {
|
||||
app_uid: string;
|
||||
action: string;
|
||||
@@ -277,6 +309,14 @@ export type EventMap = {
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Side-effect-free "is the card gate usable?" probe. The extension stamps
|
||||
// `enabled` from its own config and does nothing else — no provider call,
|
||||
// no user state, nothing billed. It exists so the SMS-to-card fallback can
|
||||
// default on only where a card gate actually works, which means it gets
|
||||
// asked often and must stay cheap.
|
||||
'puter.card-verification.status': {
|
||||
enabled: boolean | null;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
@@ -314,6 +354,23 @@ export type EventMap = {
|
||||
stripe_customer_id?: string | null;
|
||||
};
|
||||
|
||||
// ---- Team billing ---- the trigger, never the charge ----
|
||||
'team.account.created': TeamBillingEvent;
|
||||
/** `held_bytes` is what to bill storage on while the seat is off. */
|
||||
'team.account.disabled': TeamBillingEvent & { held_bytes: number };
|
||||
'team.account.enabled': TeamBillingEvent & { held_bytes: number };
|
||||
/** Emitted pre-delete: the membership row cascades away with the user. */
|
||||
'team.account.deleted': TeamBillingEvent;
|
||||
/** Per-seat charges stop; byte charges do not, the accounts remain. */
|
||||
'team.deleted': TeamBillingContext & { account_count: number };
|
||||
/** A budget line was crossed. Emitted on transition only, never per request. */
|
||||
'metering.credit-state': {
|
||||
user_uuid: string;
|
||||
state: 'near-limit' | 'exhausted';
|
||||
allowance_used: number;
|
||||
month_usage_allowance: number;
|
||||
};
|
||||
|
||||
// ---- Filesystem ----
|
||||
'fs.copy.node': {
|
||||
source: unknown;
|
||||
@@ -334,6 +391,18 @@ export type EventMap = {
|
||||
};
|
||||
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
/** A new entry, one key per flavor; `fs.write.file` is the overwrite. */
|
||||
'fs.create.file': FsCreateEvent;
|
||||
'fs.create.directory': FsCreateEvent;
|
||||
'fs.create.shortcut': FsCreateEvent;
|
||||
'fs.create.symlink': FsCreateEvent;
|
||||
/** In-place name change. A move emits `fs.move.node` instead. */
|
||||
'fs.rename': FsCreateEvent & {
|
||||
old_name: string;
|
||||
new_name: string;
|
||||
old_path: string;
|
||||
new_path: string;
|
||||
};
|
||||
'fs.storage.upload-progress': {
|
||||
upload_tracker: unknown;
|
||||
context: unknown;
|
||||
@@ -347,6 +416,57 @@ export type EventMap = {
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- Key-value ----
|
||||
/**
|
||||
* Keys of one namespace that changed together, announced after the write
|
||||
* commits. `namespace` is `v1:<userUuid>:<appUid>` and `userId` is the same
|
||||
* user by its row id — the only part a listener cannot derive from the
|
||||
* namespace, and the one dispatch keys on.
|
||||
*
|
||||
* Distinct from `outer.kv.cacheInvalidated`, which carries encoded cache
|
||||
* keys and is suppressed entirely when the read cache is off.
|
||||
*/
|
||||
'kv.mutated': {
|
||||
namespace: string;
|
||||
userId: number;
|
||||
keys: string[];
|
||||
op: KvOp;
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
* key enumeration is truncated for a large namespace, so the keys it saw
|
||||
* are not the keys it removed.
|
||||
*/
|
||||
'kv.flushed': { namespace: string; userId: number };
|
||||
|
||||
// ---- Notifications ----
|
||||
/**
|
||||
* 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
|
||||
* so a listener never has to read the row back.
|
||||
*/
|
||||
'notif.created': {
|
||||
userId: number;
|
||||
userUuid: string;
|
||||
uid: string;
|
||||
type: string;
|
||||
audience: string;
|
||||
appUid: string | null;
|
||||
value: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
};
|
||||
/**
|
||||
* One notification, addressed at whatever sockets each region holds for the
|
||||
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
|
||||
* every region applies it exactly once — which is what keeps a socket from
|
||||
* receiving the same notification twice.
|
||||
*/
|
||||
'outer.notif.delivery': {
|
||||
userId: number;
|
||||
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
|
||||
response: unknown;
|
||||
};
|
||||
|
||||
// ---- Metering ----
|
||||
// Recurring charges are pure mechanism here: the metering service knows
|
||||
// when to ask (once per user per month, the first time that month's usage
|
||||
@@ -391,6 +511,14 @@ export type EventMap = {
|
||||
worker: string;
|
||||
};
|
||||
|
||||
// An app's events worker coming into being (its handler count going 0→1)
|
||||
// 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 };
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
cacheKey: string[];
|
||||
@@ -412,6 +540,37 @@ export type EventMap = {
|
||||
'outer.permission.flatInvalidated': {
|
||||
entries: Array<{ holderUserId: number; permission: 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
|
||||
* bump produced so a listener can order two bumps it sees out of order.
|
||||
*
|
||||
* The dispatch hot path never reads the counter — it is the broadcast that
|
||||
* invalidates, which is what keeps an unsubscribed write at zero Redis
|
||||
* commands. Hence `outer.pubsub.*`: the cache is a per-process map, the
|
||||
* webhook reaches one node per peer region, and only the Redis fan reaches
|
||||
* that node's siblings — a node the bump never reached has nothing that
|
||||
* expires.
|
||||
*/
|
||||
'outer.pubsub.events.generationBumped': {
|
||||
userId: number;
|
||||
generation: number;
|
||||
/** Whether the table changed; only then does a peer need to re-read it. */
|
||||
durable: boolean;
|
||||
};
|
||||
/**
|
||||
* A user's sockets moved between regions, so every node must drop what it
|
||||
* cached about where they are. Bumped on a first connect, a last
|
||||
* disconnect, and every repair — never on a timer, which is what keeps
|
||||
* presence reads proportional to session churn rather than to event volume.
|
||||
* `outer.pubsub.*` for the same reason as the generation bump: a
|
||||
* per-process cache that only the Redis fan reaches on a region's other
|
||||
* nodes.
|
||||
*/
|
||||
'outer.pubsub.events.presenceBumped': {
|
||||
userId: number;
|
||||
generation: number;
|
||||
};
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
@@ -427,8 +586,8 @@ export type EventMap = {
|
||||
'outer.gui.item.moved': GuiEvent;
|
||||
'outer.gui.item.pending': GuiEvent;
|
||||
'outer.gui.item.removed': GuiEvent;
|
||||
'outer.gui.item.renamed': GuiEvent;
|
||||
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.persisted': GuiEvent<{ uid: string }>;
|
||||
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
|
||||
'outer.gui.notif.unreads': GuiEvent<{
|
||||
unreads: { uid: string; notification: unknown }[];
|
||||
@@ -470,6 +629,30 @@ export type EventMap = {
|
||||
'web.socket.connected': { socket: unknown; user: unknown };
|
||||
'web.socket.user-connected': { socket: unknown; user: unknown };
|
||||
|
||||
/**
|
||||
* Sessions were revoked for a user. Anything holding one of them open — a
|
||||
* live socket, say — should drop it: `revoked_at` is otherwise only read on
|
||||
* the next handshake.
|
||||
*/
|
||||
'auth.sessions.revoked': { user_id: number; session_uids: string[] };
|
||||
|
||||
/**
|
||||
* A grant was withdrawn, so whatever was standing on it has to be settled
|
||||
* rather than left to fail its next check.
|
||||
*
|
||||
* Local rather than `outer.*` on purpose: a listener settles shared state,
|
||||
* and one region doing it covers every other. Emitted once per revoke call,
|
||||
* not once per row it affects.
|
||||
*/
|
||||
'permission.revoked': {
|
||||
/** Whose access went. */
|
||||
holderUserId: number;
|
||||
/** The app the grant was to, or `null` for a user-to-user grant. */
|
||||
appUid: string | null;
|
||||
/** The grant string, or `null` when every grant to the app went. */
|
||||
permission: string | null;
|
||||
};
|
||||
|
||||
// ---- Extension hooks / misc ----
|
||||
'puter.gui.addons': {
|
||||
prependHeadContent?: string[];
|
||||
@@ -610,11 +793,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `DispatcherInvokeTransport` against an injected fetch, so the handled/error
|
||||
* header contract is pinned without a real dispatcher on the other end.
|
||||
*/
|
||||
|
||||
import type { fetch as undiciFetch } from 'undici';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
DispatcherInvokeTransport,
|
||||
EVENTS_DEPLOYED_HEADER,
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
EVENTS_DISPATCH_PATH,
|
||||
EVENTS_ERROR_HEADER,
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EventsWorkerInvokerClient,
|
||||
type WorkerInvokeRequest,
|
||||
} from './EventsWorkerInvokerClient.js';
|
||||
|
||||
type FetchImpl = typeof undiciFetch;
|
||||
|
||||
const CALL = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
key: 'k1:test',
|
||||
body: '{}',
|
||||
timeoutMs: 5_000,
|
||||
};
|
||||
|
||||
/** A stub fetch that answers the same way on every call. */
|
||||
const stubFetch = (
|
||||
status: number,
|
||||
headers: Record<string, string> = {},
|
||||
): FetchImpl =>
|
||||
(async () =>
|
||||
new Response(null, { status, headers })) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that never answers until the abort signal fires. */
|
||||
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('This operation was aborted')),
|
||||
);
|
||||
})) as unknown as FetchImpl;
|
||||
|
||||
/** A stub fetch that records the URL it was called with. */
|
||||
const capturingFetch = (
|
||||
calls: string[],
|
||||
status = 200,
|
||||
): FetchImpl =>
|
||||
(async (url: string) => {
|
||||
calls.push(url);
|
||||
return new Response(null, { status });
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
describe('DispatcherInvokeTransport', () => {
|
||||
it('reports a handled 400 with no error — the handler`s own refusal', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 400,
|
||||
handled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(404),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 404,
|
||||
handled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a dispatch-error header into a null status with the reason', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-failed (502)',
|
||||
dispatchReason: 'deploy-failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the deployed header when the call says so', async () => {
|
||||
const headers: Array<Record<string, string>> = [];
|
||||
const fetchImpl = (async (_url: string, init?: RequestInit) => {
|
||||
headers.push(init?.headers as Record<string, string>);
|
||||
return new Response(null, { status: 200 });
|
||||
}) as unknown as FetchImpl;
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await transport.send(CALL);
|
||||
await transport.send({ ...CALL, deployed: true });
|
||||
|
||||
expect(headers[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headers[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
});
|
||||
|
||||
it('reports a handled 200 as settled with no error', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 200,
|
||||
handled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a 429 through as-is', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(429),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 429,
|
||||
handled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers null with an error when the request times out', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: hangingFetch,
|
||||
});
|
||||
const result = await transport.send({ ...CALL, timeoutMs: 10 });
|
||||
expect(result.status).toBeNull();
|
||||
expect(result.error).toMatch(/abort/i);
|
||||
});
|
||||
|
||||
it('surfaces the runtime`s own error header alongside its status', async () => {
|
||||
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(500, {
|
||||
[EVENTS_HANDLED_HEADER]: '1',
|
||||
[EVENTS_ERROR_HEADER]: 'handler-threw',
|
||||
}),
|
||||
});
|
||||
expect(await transport.send(CALL)).toEqual({
|
||||
status: 500,
|
||||
handled: true,
|
||||
error: 'handler-threw',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a path prefix on the dispatcher URL', async () => {
|
||||
const calls: string[] = [];
|
||||
const transport = new DispatcherInvokeTransport(
|
||||
'http://dispatcher/prefix/',
|
||||
's',
|
||||
{ fetchImpl: capturingFetch(calls) },
|
||||
);
|
||||
await transport.send(CALL);
|
||||
expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]);
|
||||
});
|
||||
});
|
||||
|
||||
const REQUEST: WorkerInvokeRequest = {
|
||||
script: 'evw-test',
|
||||
appUid: 'app-test',
|
||||
handler: 'h',
|
||||
token: 't',
|
||||
key: 'k1:test',
|
||||
event: {},
|
||||
ctx: {},
|
||||
};
|
||||
|
||||
const makeClient = () => new EventsWorkerInvokerClient({} as unknown as IConfig);
|
||||
|
||||
/** Answers `missing` until it sees the deployed header, then settles. */
|
||||
const missUntilDeployedFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
||||
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 FetchImpl;
|
||||
|
||||
/** Answers `missing` no matter what, deployed header included. */
|
||||
const alwaysMissingFetch = (calls: unknown[]): FetchImpl =>
|
||||
(async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 404,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
/**
|
||||
* Answers `deploy-timeout` once, then 200 once it sees the deployed header —
|
||||
* the shape of a script that finished propagating between the two calls.
|
||||
*/
|
||||
const deployTimeoutThenOkFetch = (
|
||||
headersSeen: Array<Record<string, string>>,
|
||||
): FetchImpl =>
|
||||
(async (_url: string, init?: RequestInit) => {
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
headersSeen.push(headers);
|
||||
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { [EVENTS_HANDLED_HEADER]: '1' },
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 503,
|
||||
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout' },
|
||||
});
|
||||
}) as unknown as FetchImpl;
|
||||
|
||||
describe('EventsWorkerInvokerClient deploy-on-miss', () => {
|
||||
it('deploys the script and retries once with the deployed header', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: missUntilDeployedFetch(headersSeen),
|
||||
}),
|
||||
);
|
||||
const missCalls: Array<[string, string]> = [];
|
||||
client.setMissHandler(async (appUid, script) => {
|
||||
missCalls.push([appUid, script]);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(missCalls).toEqual([[REQUEST.appUid, REQUEST.script]]);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('does not retry when the miss handler reports the script is stale', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
client.setMissHandler(async () => 'stale');
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'deploy: stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('never asks the miss handler twice for one invocation', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
let handlerCalls = 0;
|
||||
client.setMissHandler(async () => {
|
||||
handlerCalls++;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(handlerCalls).toBe(1);
|
||||
// The first send plus the one retry — never a third.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(result.outcome).toBe('retriable');
|
||||
expect(result.status).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves behavior unchanged with no miss handler set', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: alwaysMissingFetch(calls),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: missing (404)',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trigger the miss handler for a forbidden dispatch reason', async () => {
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: stubFetch(403, {
|
||||
[EVENTS_DISPATCH_ERROR_HEADER]: 'forbidden',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let handlerCalled = false;
|
||||
client.setMissHandler(async () => {
|
||||
handlerCalled = true;
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
expect(handlerCalled).toBe(false);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: forbidden (403)',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a deploy-timeout with the deployed header instead of redeploying', async () => {
|
||||
const headersSeen: Array<Record<string, string>> = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: deployTimeoutThenOkFetch(headersSeen),
|
||||
}),
|
||||
);
|
||||
const deployCalls: unknown[] = [];
|
||||
client.setMissHandler(async () => {
|
||||
deployCalls.push(null);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
// The script was already deployed once — the one that led to this
|
||||
// deploy-timeout answer — so this client must not add a second.
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(headersSeen).toHaveLength(2);
|
||||
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
|
||||
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
|
||||
expect(result).toEqual({ outcome: 'settled', status: 200 });
|
||||
});
|
||||
|
||||
it('gives up after one retry when propagation is still not done', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = makeClient();
|
||||
client.setTransport(
|
||||
new DispatcherInvokeTransport('http://dispatcher', 's', {
|
||||
fetchImpl: (async () => {
|
||||
calls.push(null);
|
||||
return new Response(null, {
|
||||
status: 503,
|
||||
headers: {
|
||||
[EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout',
|
||||
},
|
||||
});
|
||||
}) as unknown as FetchImpl,
|
||||
}),
|
||||
);
|
||||
const deployCalls: unknown[] = [];
|
||||
client.setMissHandler(async () => {
|
||||
deployCalls.push(null);
|
||||
return 'deployed';
|
||||
});
|
||||
|
||||
const result = await client.invoke(REQUEST);
|
||||
|
||||
// The first send plus the one retry — never a third, and never a
|
||||
// deploy. The delivery comes back on its own backoff instead.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(deployCalls).toHaveLength(0);
|
||||
expect(result).toEqual({
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: 'dispatcher: deploy-timeout (503)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Claims the global dispatcher slot for Node's own fetch before npm `undici`
|
||||
// can — see the guard's own comment.
|
||||
import '../../util/nodeFetchDispatcherGuard.js';
|
||||
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { PuterClient } from '../types.js';
|
||||
|
||||
/**
|
||||
* The call that leaves the platform and runs an app's own code.
|
||||
*
|
||||
* Events workers are unreachable from the internet: deployed into their own
|
||||
* dispatch namespace and reached only through the events dispatcher, which
|
||||
* answers on its own hostname behind the internal secret.
|
||||
*
|
||||
* POST <events dispatcher>/invoke
|
||||
* 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-key: <key derived for that script>
|
||||
* { handler, event, ctx, token }
|
||||
*
|
||||
* The delivery token rides in the body, not a header, so nothing between here
|
||||
* and the isolate can treat it as its own authorization.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** The one route an events worker answers. */
|
||||
export const EVENTS_INVOKE_PATH = '/__events/invoke';
|
||||
|
||||
/** The route the events dispatcher takes an invocation on. */
|
||||
export const EVENTS_DISPATCH_PATH = '/invoke';
|
||||
|
||||
/**
|
||||
* Header the dispatcher marks its own failures with. Anything carrying it never
|
||||
* reached a handler, so its status says nothing about the delivery.
|
||||
*/
|
||||
export const EVENTS_DISPATCH_ERROR_HEADER = 'x-puter-events-dispatch';
|
||||
|
||||
/**
|
||||
* Header a script's own answer carries, whatever its status — set by the
|
||||
* runtime itself and re-stamped by the dispatcher, so a 4xx with neither this
|
||||
* nor the dispatch-error header came from neither (an edge 404, a WAF) and is
|
||||
* retriable rather than a handler's refusal.
|
||||
*/
|
||||
export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
|
||||
|
||||
/** Machine-readable reason on the runtime's own failure answers. */
|
||||
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
|
||||
|
||||
/**
|
||||
* Set on the retry after this backend deploys a missing script itself, so the
|
||||
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
|
||||
* straight to retrying the namespace.
|
||||
*/
|
||||
export const EVENTS_DEPLOYED_HEADER = 'x-puter-events-deployed';
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons this backend can resolve itself by deploying, rather
|
||||
* than wait on the rehydrate callback finding a backend behind the dispatcher's
|
||||
* own hostname. `deploy-timeout` is not one of them: it is only ever answered
|
||||
* after a deploy that succeeded, so the script exists and another upload of it
|
||||
* cannot make the namespace see it any sooner.
|
||||
*/
|
||||
const SELF_DEPLOYABLE_MISS_REASONS = new Set([
|
||||
'missing',
|
||||
'deploy-failed',
|
||||
'deploy-error',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dispatch-error reasons where the deploy already happened and only propagation
|
||||
* is still catching up — one prompt retry with the deployed header is worth it,
|
||||
* without spending another upload on it.
|
||||
*/
|
||||
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
|
||||
|
||||
/** What a self-deploy attempt did, and to which app/script it applies. */
|
||||
export type WorkerMissHandler = (
|
||||
appUid: string,
|
||||
script: string,
|
||||
) => Promise<'deployed' | string>;
|
||||
|
||||
/** How long a handler has to answer before the attempt is abandoned. */
|
||||
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Connections held open to the dispatcher, so a busy fleet is not
|
||||
* re-handshaking. The 30s invoke abort starts when a request is queued, not
|
||||
* when it gets a connection, so a saturated pool shows up as timeouts.
|
||||
*/
|
||||
const INVOKE_CONNECTIONS = 256;
|
||||
const INVOKE_KEEP_ALIVE_MS = 30_000;
|
||||
|
||||
/** One invocation, as the events service hands it over. */
|
||||
export interface WorkerInvokeRequest {
|
||||
/** Script the app's current handler set deploys as. */
|
||||
script: string;
|
||||
appUid: string;
|
||||
handler: string;
|
||||
/** The subscriber's app session token. */
|
||||
token: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
event: unknown;
|
||||
ctx: unknown;
|
||||
}
|
||||
|
||||
/** One invocation, as a transport delivers it. */
|
||||
export interface EventsInvokeCall {
|
||||
script: string;
|
||||
appUid: string;
|
||||
/** Value the script compares against its own invoke-key binding. */
|
||||
key: string;
|
||||
/** Serialized `{ handler, event, ctx, token }`. */
|
||||
body: string;
|
||||
timeoutMs: number;
|
||||
/** Set on the retry after this backend deployed the script itself. */
|
||||
deployed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an invocation is delivered.
|
||||
*
|
||||
* `status` is what the handler answered with. A `null` status means nothing
|
||||
* ran: no transport, no script, or a dispatcher that refused us — all of which
|
||||
* are the platform's fault and so retriable. `handled` is set only when the
|
||||
* answer is provably the script's own (the handled header); a 4xx without it
|
||||
* came from something between here and the script, not a handler's refusal.
|
||||
*/
|
||||
export interface EventsInvokeTransport {
|
||||
send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
/** Set only by the dispatcher transport, naming its own failure. */
|
||||
dispatchReason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the call did.
|
||||
*
|
||||
* - `settled` — the handler took the delivery (2xx).
|
||||
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
|
||||
* the same code, so it is not retried.
|
||||
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
|
||||
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
|
||||
* reach the worker at all.
|
||||
*/
|
||||
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
|
||||
|
||||
export interface WorkerInvokeResult {
|
||||
outcome: WorkerInvokeOutcome;
|
||||
/** The status the handler answered with, or `null` when it never did. */
|
||||
status: number | null;
|
||||
/** Why it could not answer, when nothing did. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const NO_TRANSPORT =
|
||||
'no events dispatcher configured (url and internal secret)';
|
||||
|
||||
export class EventsWorkerInvokerClient extends PuterClient {
|
||||
/** Set only where invocations do not leave the process — local development. */
|
||||
#transport: EventsInvokeTransport | null = null;
|
||||
#dispatcher: DispatcherInvokeTransport | null = null;
|
||||
/** Lets this backend resolve a dispatcher miss instead of waiting on it. */
|
||||
#missHandler: WorkerMissHandler | null = null;
|
||||
|
||||
/** Replaces the dispatcher with an in-process runtime. */
|
||||
setTransport(transport: EventsInvokeTransport): void {
|
||||
this.#transport = transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploys a script the dispatcher reports missing, in place of the
|
||||
* rehydrate callback — for the case where that callback lands on a backend
|
||||
* other than this one. Set once; `invoke()` calls it at most once per
|
||||
* delivery and never loops.
|
||||
*/
|
||||
setMissHandler(handler: WorkerMissHandler): void {
|
||||
this.#missHandler = handler;
|
||||
}
|
||||
|
||||
override onServerShutdown(): void {
|
||||
this.#dispatcher?.close();
|
||||
this.#dispatcher = null;
|
||||
}
|
||||
|
||||
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
|
||||
const transport = this.#pickTransport();
|
||||
if (!transport)
|
||||
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
|
||||
|
||||
const call: EventsInvokeCall = {
|
||||
script: request.script,
|
||||
appUid: request.appUid,
|
||||
key: request.key,
|
||||
body: JSON.stringify({
|
||||
handler: request.handler,
|
||||
event: request.event,
|
||||
ctx: request.ctx,
|
||||
token: request.token,
|
||||
}),
|
||||
timeoutMs: this.#timeoutMs(),
|
||||
};
|
||||
|
||||
let result = await transport.send(call);
|
||||
if (result.status === null && result.dispatchReason) {
|
||||
if (
|
||||
this.#missHandler &&
|
||||
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
const outcome = await this.#missHandler(
|
||||
request.appUid,
|
||||
request.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return {
|
||||
outcome: 'retriable',
|
||||
status: null,
|
||||
error: `deploy: ${outcome}`,
|
||||
};
|
||||
result = await transport.send({ ...call, deployed: true });
|
||||
} else if (
|
||||
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
|
||||
) {
|
||||
// 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 { status, handled, error } = result;
|
||||
if (status === null)
|
||||
return { outcome: 'retriable', status: null, error };
|
||||
|
||||
const outcome = outcomeForStatus(status, handled === true);
|
||||
// An unmarked 4xx (429 aside — that one is always retriable) is not
|
||||
// the handler's own answer; say so even when the transport did not,
|
||||
// so the reason a delivery kept retrying shows up in the log rather
|
||||
// than reading as an unexplained retry.
|
||||
const unmarkedFourxx =
|
||||
status !== 429 && status >= 400 && status < 500 && !handled;
|
||||
const reportedError =
|
||||
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
|
||||
return {
|
||||
outcome,
|
||||
status,
|
||||
...(reportedError ? { error: reportedError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
#pickTransport(): EventsInvokeTransport | null {
|
||||
if (this.#transport) return this.#transport;
|
||||
const { dispatcherUrl, internalSecret } = this.config.events ?? {};
|
||||
// Without the secret the dispatcher would refuse every call, which is
|
||||
// a misconfiguration worth reporting once per attempt rather than a
|
||||
// round trip.
|
||||
if (!dispatcherUrl || !internalSecret) return null;
|
||||
this.#dispatcher ??= new DispatcherInvokeTransport(
|
||||
dispatcherUrl,
|
||||
internalSecret,
|
||||
);
|
||||
return this.#dispatcher;
|
||||
}
|
||||
|
||||
#timeoutMs(): number {
|
||||
const configured = this.config.events?.invokeTimeoutMs;
|
||||
return typeof configured === 'number' && configured > 0
|
||||
? configured
|
||||
: EVENTS_INVOKE_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
#agent: Agent | null = null;
|
||||
|
||||
constructor(
|
||||
dispatcherUrl: string,
|
||||
secret: string,
|
||||
options: { fetchImpl?: typeof undiciFetch } = {},
|
||||
) {
|
||||
this.#url = dispatcherUrl;
|
||||
this.#secret = secret;
|
||||
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
void this.#agent?.close().catch(() => {});
|
||||
this.#agent = null;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
dispatchReason?: string;
|
||||
}> {
|
||||
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(),
|
||||
});
|
||||
// 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(() => {});
|
||||
|
||||
// The dispatcher never reached a handler, so its status is not the
|
||||
// delivery's answer.
|
||||
const dispatchError = response.headers.get(
|
||||
EVENTS_DISPATCH_ERROR_HEADER,
|
||||
);
|
||||
if (dispatchError !== null)
|
||||
return {
|
||||
status: null,
|
||||
error: `dispatcher: ${dispatchError} (${response.status})`,
|
||||
dispatchReason: dispatchError,
|
||||
};
|
||||
|
||||
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
|
||||
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
|
||||
return {
|
||||
status: response.status,
|
||||
handled,
|
||||
...(errorHeader ? { error: errorHeader } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** One pool per process, built on first use and closed with the server. */
|
||||
#dispatcherFor(): Agent {
|
||||
this.#agent ??= new Agent({
|
||||
connections: INVOKE_CONNECTIONS,
|
||||
keepAliveTimeout: INVOKE_KEEP_ALIVE_MS,
|
||||
keepAliveMaxTimeout: INVOKE_KEEP_ALIVE_MS,
|
||||
});
|
||||
return this.#agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
|
||||
* only when `handled` says the answer is provably the script's own — an
|
||||
* unmarked 4xx came from something between here and the handler.
|
||||
*/
|
||||
export const outcomeForStatus = (
|
||||
status: number,
|
||||
handled: boolean,
|
||||
): WorkerInvokeOutcome => {
|
||||
if (status >= 200 && status < 300) return 'settled';
|
||||
if (status === 429) return 'retriable';
|
||||
if (status >= 400 && status < 500)
|
||||
return handled ? 'terminal' : 'retriable';
|
||||
return 'retriable';
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { AlarmClient } from './alarm/AlarmClient';
|
||||
import { DatabaseClientFactory } from './database';
|
||||
import { EmailClient } from './email/EmailClient';
|
||||
import { EventClient } from './event/EventClient';
|
||||
import { EventsWorkerInvokerClient } from './events/EventsWorkerInvokerClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
@@ -32,6 +33,7 @@ export const puterClients = {
|
||||
db: DatabaseClientFactory,
|
||||
email: EmailClient,
|
||||
event: EventClient,
|
||||
eventsWorkerInvoker: EventsWorkerInvokerClient,
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { driversContainers } from '../../exports.js';
|
||||
import {
|
||||
APP_ICON_SIZES,
|
||||
ICON_DATA_URL_MIME_ALLOWLIST,
|
||||
isTrustedIconHost,
|
||||
} from '../../util/appIcon.js';
|
||||
@@ -414,8 +415,6 @@ export class AppController extends PuterController {
|
||||
//
|
||||
// ⚠ FLAG: Missing sharp-based resize pipeline; serves the original.
|
||||
|
||||
const ICON_SIZES = [16, 32, 64, 128, 256, 512];
|
||||
|
||||
// Neutering headers for any response that echoes an icon byte
|
||||
// stream on the main origin. `image/svg+xml` is in our MIME
|
||||
// allow-list — it's a legitimate image format, and our own
|
||||
@@ -457,7 +456,7 @@ export class AppController extends PuterController {
|
||||
res.status(400).send('Missing app_uid');
|
||||
return;
|
||||
}
|
||||
if (!ICON_SIZES.includes(size)) {
|
||||
if (!APP_ICON_SIZES.includes(size)) {
|
||||
res.status(400).send('Invalid size');
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,7 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { v4 as uuidv4, validate as validateUuid } from 'uuid';
|
||||
import validator from 'validator';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
@@ -54,37 +55,55 @@ import {
|
||||
} from '../../services/auth/OTPUtil.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
|
||||
import type { CardFallbackDeps } from '../../util/cardFallback.js';
|
||||
import {
|
||||
CARD_FALLBACK_OPEN_TTL_SECONDS,
|
||||
SEND_PHONE_RATE_LIMIT,
|
||||
SEND_PHONE_RATE_WINDOW_MS,
|
||||
cardFallbackAfterAttempts,
|
||||
cardFallbackFlagKey,
|
||||
isCardFallbackEligible,
|
||||
isCardFallbackEnabled,
|
||||
phoneAttemptsKey,
|
||||
} from '../../util/cardFallback.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { parsePhone } from '../../util/phone.js';
|
||||
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
generateDefaultFsentries,
|
||||
promoteToVerifiedGroup,
|
||||
} from '../../util/userProvisioning.js';
|
||||
import {
|
||||
assertBoundedManageGrant,
|
||||
isKvSharePermission,
|
||||
} from '../../services/events/kvShares.js';
|
||||
import {
|
||||
APP_DATA_PERMISSION_PREFIX,
|
||||
appDataSharingAllowed,
|
||||
parseAppDataPermission,
|
||||
} from '../../services/permission/appDataScopes.js';
|
||||
import {
|
||||
assertCreatablePath,
|
||||
fsCreateKindFor,
|
||||
MAX_CREATED_ENTRIES_PER_GRANT,
|
||||
parseCreateFlag,
|
||||
parseFsPathPermission,
|
||||
type FsCreateKind,
|
||||
} from '../../services/permission/fsPathPermission.js';
|
||||
import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const USERNAME_REGEX = /^\w{1,}$/;
|
||||
const USERNAME_MAX_LENGTH = 45;
|
||||
export const USERNAME_REGEX = /^\w{1,}$/;
|
||||
export const USERNAME_MAX_LENGTH = 45;
|
||||
const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// One consent prompt covers a handful of scopes at most. The cap keeps a
|
||||
// crafted request from turning a single grant call into a bulk write.
|
||||
const MAX_PERMISSIONS_PER_REQUEST = 16;
|
||||
const DISPATCH_ID_MAX_LENGTH = 128;
|
||||
// Default SMS send attempts before the card fallback opens.
|
||||
const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2;
|
||||
// /send-confirm-phone route rate limit. Also caps the fallback's
|
||||
// `after_attempts`: requests past the route limit are rejected in middleware
|
||||
// and never reach the attempt counter, so a higher threshold could never be
|
||||
// crossed.
|
||||
const SEND_PHONE_RATE_LIMIT = 10;
|
||||
const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000;
|
||||
|
||||
// -- Post-login route limits -----------------------------------------
|
||||
//
|
||||
@@ -173,9 +192,6 @@ const SESSION_LIMIT = {
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
// Once the threshold is crossed the fallback stays open this long, so the
|
||||
// user can finish the card flow without racing the attempt counter's expiry.
|
||||
const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60;
|
||||
// How long a failed-SMS-send record stays readable by its error_id — long
|
||||
// enough to cover the typical support round-trip.
|
||||
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
@@ -194,7 +210,7 @@ const cardAlreadyVerified = (user: {
|
||||
requires_card_verification?: boolean;
|
||||
}): boolean =>
|
||||
Boolean(user.card_fingerprint) && !user.requires_card_verification;
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
export const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
'root',
|
||||
@@ -239,9 +255,12 @@ export class AuthController extends PuterController {
|
||||
],
|
||||
})
|
||||
async loginWait(req: Request, res: Response) {
|
||||
const { session } = req.body;
|
||||
// Destructuring an absent body is a TypeError, not the 400 a request
|
||||
// with no session id deserves.
|
||||
const session = (req.body as { session?: unknown } | undefined)
|
||||
?.session;
|
||||
// validate uuid to prevent ultra long key or listening on pubsub.login.*
|
||||
if (!session || !validateUuid(session)) {
|
||||
if (typeof session !== 'string' || !validateUuid(session)) {
|
||||
throw new HttpError(400, 'session is required.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
@@ -343,7 +362,7 @@ export class AuthController extends PuterController {
|
||||
],
|
||||
})
|
||||
async loginSet(req: Request, res: Response) {
|
||||
const { session, auth_token } = req.body;
|
||||
const { session, auth_token } = req.body ?? {};
|
||||
if (!session || !auth_token || !validateUuid(session)) {
|
||||
throw new HttpError(400, 'session and auth_token are required.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -382,7 +401,7 @@ export class AuthController extends PuterController {
|
||||
],
|
||||
})
|
||||
async handleLogin(req: Request, res: Response): Promise<void> {
|
||||
const { username, email, password } = req.body;
|
||||
const { username, email, password } = req.body ?? {};
|
||||
|
||||
if (!username && !email) {
|
||||
throw new HttpError(400, 'Username or email is required.', {
|
||||
@@ -451,6 +470,15 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'password_mismatch',
|
||||
});
|
||||
}
|
||||
// An administrator-issued temporary password that was never used dies
|
||||
// rather than becoming a standing credential the team holds.
|
||||
if (isTemporaryPasswordExpired(user)) {
|
||||
throw new HttpError(
|
||||
401,
|
||||
'This temporary password has expired. Ask your team administrator for a new one.',
|
||||
{ legacyCode: 'temporary_password_expired' },
|
||||
);
|
||||
}
|
||||
|
||||
const reauthAuthId = this.#extractAuthIdFromReauthToken(
|
||||
req.body.reauth_token,
|
||||
@@ -499,7 +527,7 @@ export class AuthController extends PuterController {
|
||||
],
|
||||
})
|
||||
async handleLoginOtp(req: Request, res: Response): Promise<void> {
|
||||
const { token, code } = req.body;
|
||||
const { token, code } = req.body ?? {};
|
||||
if (!token)
|
||||
throw new HttpError(400, 'token is required.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -565,7 +593,7 @@ export class AuthController extends PuterController {
|
||||
],
|
||||
})
|
||||
async handleLoginRecoveryCode(req: Request, res: Response): Promise<void> {
|
||||
const { token, code } = req.body;
|
||||
const { token, code } = req.body ?? {};
|
||||
if (!token)
|
||||
throw new HttpError(400, 'token is required.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -841,6 +869,10 @@ export class AuthController extends PuterController {
|
||||
// guarantee — everything between here and the insert widens the window,
|
||||
// so the check runs again against the primary immediately before the
|
||||
// write, and the unique index catches whatever still slips through.
|
||||
const clientIp: string | null =
|
||||
req.ip || req.socket?.remoteAddress || null;
|
||||
const proxyIpChain = req.headers['x-forwarded-for'];
|
||||
|
||||
let pseudo_user = is_temp
|
||||
? null
|
||||
: await this.#resolveSignupEmailClaim(body.email);
|
||||
@@ -855,12 +887,9 @@ export class AuthController extends PuterController {
|
||||
const validateEvent = {
|
||||
req,
|
||||
data: body,
|
||||
ip: ((req.headers?.['x-forwarded-for'] as string | undefined) ||
|
||||
(req as unknown as { connection?: { remoteAddress?: string } })
|
||||
.connection?.remoteAddress ||
|
||||
req.ip ||
|
||||
req.socket?.remoteAddress ||
|
||||
null) as string | null,
|
||||
// `req.ip` honors `trust proxy`; reading x-forwarded-for directly
|
||||
// would let a client pick its own per-IP abuse bucket.
|
||||
ip: clientIp,
|
||||
email: body.email,
|
||||
// The same canonical form `email.validate` was given, so a check
|
||||
// in the abuse harness can look up the verdict that hook cached
|
||||
@@ -1035,9 +1064,6 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
} else {
|
||||
// -- New user ----------------------------------------
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || null;
|
||||
const proxyIpChain = req.headers['x-forwarded-for'];
|
||||
|
||||
try {
|
||||
user = await this.stores.user.create({
|
||||
username: body.username,
|
||||
@@ -1058,13 +1084,16 @@ export class AuthController extends PuterController {
|
||||
fingerprint,
|
||||
},
|
||||
signup_ip: clientIp,
|
||||
signup_ip_forwarded: proxyIpChain,
|
||||
// The abuse harness and the admin IP lookup both key on
|
||||
// this column, so it holds the trusted client address; the
|
||||
// raw forwarded chain stays in `audit_metadata.ip_fwd`.
|
||||
signup_ip_forwarded: clientIp,
|
||||
signup_user_agent: req.headers?.['user-agent'] ?? null,
|
||||
signup_origin:
|
||||
(req.headers?.origin as string | null) ?? null,
|
||||
signup_server: (this.config as { serverId?: string })
|
||||
.serverId,
|
||||
referrer: req.body.referrer ?? null,
|
||||
referrer: body.referrer ?? null,
|
||||
last_activity_ts: signupSqlTs,
|
||||
reputation: validateEvent.reputation,
|
||||
// Phone collected later in the verification dialog (null now).
|
||||
@@ -1120,8 +1149,9 @@ export class AuthController extends PuterController {
|
||||
) {
|
||||
const sendCode = body.send_confirmation_code ?? true;
|
||||
try {
|
||||
let sent;
|
||||
if (sendCode) {
|
||||
await this.clients.email.send(
|
||||
sent = await this.clients.email.send(
|
||||
user!.email!,
|
||||
'email_verification_code',
|
||||
{
|
||||
@@ -1130,14 +1160,18 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
} else {
|
||||
const link = `${this.config.origin ?? ''}/confirm-email-by-token?token=${email_confirm_token}&user_uuid=${user!.uuid}`;
|
||||
await this.clients.email.send(
|
||||
sent = await this.clients.email.send(
|
||||
user!.email!,
|
||||
'email_verification_link',
|
||||
{ link },
|
||||
);
|
||||
}
|
||||
// `null` = dropped for want of a transport; silent otherwise.
|
||||
if (sent === null) {
|
||||
this.#confirmationEmailFailed('signup', user!, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[signup] email send failed:', e);
|
||||
this.#confirmationEmailFailed('signup', user!, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1157,17 +1191,10 @@ export class AuthController extends PuterController {
|
||||
// a pseudo-user claim ends up with credentials, so it
|
||||
// reports false here. Same signal completeLogin uses.
|
||||
is_temp: user!.password === null && user!.email === null,
|
||||
ip:
|
||||
(req?.headers?.['x-forwarded-for'] as
|
||||
string | undefined) ||
|
||||
(
|
||||
req as unknown as {
|
||||
connection?: { remoteAddress?: string };
|
||||
}
|
||||
)?.connection?.remoteAddress ||
|
||||
req?.ip ||
|
||||
req?.socket?.remoteAddress ||
|
||||
null,
|
||||
// Same derivation as the validate event above — the two
|
||||
// have to agree or per-IP counters are written under one
|
||||
// key and read under another.
|
||||
ip: clientIp,
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
@@ -1269,13 +1296,16 @@ export class AuthController extends PuterController {
|
||||
|
||||
if (this.clients.email) {
|
||||
try {
|
||||
await this.clients.email.send(
|
||||
const sent = await this.clients.email.send(
|
||||
user.email,
|
||||
'email_verification_code',
|
||||
{ code },
|
||||
);
|
||||
if (sent === null) {
|
||||
this.#confirmationEmailFailed('resend', user, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[send-confirm-email] send failed:', e);
|
||||
this.#confirmationEmailFailed('resend', user, e);
|
||||
}
|
||||
}
|
||||
res.json({});
|
||||
@@ -1386,6 +1416,64 @@ export class AuthController extends PuterController {
|
||||
res.json({ email_confirmed: true, original_client_socket_id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm on a confirmation email that did not reach the recipient. A
|
||||
* `requires_email_confirmation` account is refused by
|
||||
* `requireVerifiedAccount` everywhere, so a lost code leaves an account
|
||||
* that cannot be used. `cause === null` is the silent case: `sendRaw` drops
|
||||
* the message rather than throwing when no transport is configured.
|
||||
*
|
||||
* `sole_gate` reports that no phone/card gate is outstanding either, so
|
||||
* this user is stuck on the email alone. `dedup` because one broken mail
|
||||
* path fails once per signup and is still one thing to fix.
|
||||
*/
|
||||
#confirmationEmailFailed(
|
||||
stage: 'signup' | 'resend',
|
||||
user: {
|
||||
uuid?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
requires_phone_verification?: unknown;
|
||||
requires_card_verification?: unknown;
|
||||
},
|
||||
cause: unknown,
|
||||
): void {
|
||||
const email = user.email ?? null;
|
||||
const detail =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: cause === null
|
||||
? 'no transport configured (message dropped)'
|
||||
: String(cause);
|
||||
console.warn(
|
||||
`[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` +
|
||||
`confirmation email not delivered: ${detail}`,
|
||||
);
|
||||
// Best-effort: failing to alarm must not fail the signup.
|
||||
try {
|
||||
this.clients.alarm?.create(
|
||||
`auth:confirmation-email-send-failed:${stage}`,
|
||||
'Confirmation email could not be sent — gated accounts cannot be used until it arrives',
|
||||
{
|
||||
stage,
|
||||
user_uid: user.uuid ?? null,
|
||||
username: user.username ?? null,
|
||||
email,
|
||||
email_domain: email?.split('@')[1] ?? null,
|
||||
sole_gate:
|
||||
!user.requires_phone_verification &&
|
||||
!user.requires_card_verification,
|
||||
detail,
|
||||
...(cause instanceof Error ? { error: cause } : {}),
|
||||
},
|
||||
'warning',
|
||||
{ dedup: true },
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[${stage}] confirmation-email alarm failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Phone verification (SMS via Prelude) ------------------------
|
||||
|
||||
/**
|
||||
@@ -1449,9 +1537,10 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- SMS-to-card fallback -----------------------------------------
|
||||
//
|
||||
// Once a user has made enough SMS send attempts in the rate-limit window
|
||||
// without getting through, they can verify a card instead to clear the
|
||||
// phone gate. Off unless config enables it.
|
||||
// Once a user has used up their SMS send attempts for the window without
|
||||
// getting through, they can verify a card instead to clear the phone gate.
|
||||
// On wherever both gates work unless config opts out. The rule itself lives
|
||||
// in ../../util/cardFallback.ts, because /whoami answers the same question.
|
||||
//
|
||||
// Two KV keys: a short-lived counter tied to the send rate-limit window
|
||||
// triggers the fallback, and a longer-lived "open" flag holds eligibility
|
||||
@@ -1460,30 +1549,11 @@ export class AuthController extends PuterController {
|
||||
// user is mid-way through the card flow. Every KV failure fails closed
|
||||
// (fallback unavailable), never open.
|
||||
|
||||
private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } {
|
||||
const cfg = this.config.phone_verification_card_fallback;
|
||||
const afterAttempts = Math.min(
|
||||
typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0
|
||||
? cfg.after_attempts
|
||||
: DEFAULT_CARD_FALLBACK_ATTEMPTS,
|
||||
SEND_PHONE_RATE_LIMIT,
|
||||
);
|
||||
return { enabled: Boolean(cfg?.enabled), afterAttempts };
|
||||
}
|
||||
|
||||
private phoneAttemptsKey(userId: number): string {
|
||||
return `phone-verify-attempts:${userId}`;
|
||||
}
|
||||
|
||||
private cardFallbackFlagKey(userId: number): string {
|
||||
return `card-fallback-open:${userId}`;
|
||||
}
|
||||
|
||||
// TTL ties the counter to the send rate-limit window, so it resets with it.
|
||||
private async bumpPhoneAttempts(userId: number): Promise<number> {
|
||||
try {
|
||||
const { res } = await this.stores.kv.incr({
|
||||
key: this.phoneAttemptsKey(userId),
|
||||
key: phoneAttemptsKey(userId),
|
||||
pathAndAmountMap: { attempts: 1 },
|
||||
expireAt:
|
||||
Math.floor(Date.now() / 1000) +
|
||||
@@ -1507,16 +1577,15 @@ export class AuthController extends PuterController {
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
}): Promise<boolean> {
|
||||
const attempts = await this.bumpPhoneAttempts(user.id);
|
||||
const { enabled, afterAttempts } = this.cardFallbackConfig();
|
||||
const open =
|
||||
enabled &&
|
||||
Boolean(user.requires_phone_verification) &&
|
||||
attempts >= afterAttempts;
|
||||
attempts >= cardFallbackAfterAttempts(this.config) &&
|
||||
(await isCardFallbackEnabled(this.config, this.cardFallbackDeps()));
|
||||
if (open) {
|
||||
try {
|
||||
// Plain set, so each eligible attempt refreshes the window.
|
||||
await this.stores.kv.set({
|
||||
key: this.cardFallbackFlagKey(user.id),
|
||||
key: cardFallbackFlagKey(user.id),
|
||||
value: true,
|
||||
expireAt:
|
||||
Math.floor(Date.now() / 1000) +
|
||||
@@ -1537,17 +1606,33 @@ export class AuthController extends PuterController {
|
||||
id: number;
|
||||
requires_phone_verification?: boolean | number | null;
|
||||
}): Promise<boolean> {
|
||||
const { enabled } = this.cardFallbackConfig();
|
||||
if (!enabled || !user.requires_phone_verification) return false;
|
||||
try {
|
||||
const { res } = await this.stores.kv.get({
|
||||
key: this.cardFallbackFlagKey(user.id),
|
||||
});
|
||||
return res === true;
|
||||
} catch (e) {
|
||||
console.warn('[card-verification] fallback flag read failed:', e);
|
||||
return false;
|
||||
}
|
||||
return isCardFallbackEligible(
|
||||
this.config,
|
||||
user,
|
||||
async (key) => (await this.stores.kv.get({ key })).res,
|
||||
this.cardFallbackDeps(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two facts the fallback's default rests on: SMS can only work with a
|
||||
* provider configured, and the card gate belongs to an extension, so the
|
||||
* only honest way to ask whether it is on is to ask that extension. Nothing
|
||||
* is listening on a stock build, which reads as "no card gate".
|
||||
*/
|
||||
private cardFallbackDeps(): CardFallbackDeps {
|
||||
return {
|
||||
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
|
||||
probeCardVerification: async () => {
|
||||
const statusEvent = { enabled: null as boolean | null };
|
||||
await this.clients.event?.emitAndWait(
|
||||
'puter.card-verification.status',
|
||||
statusEvent,
|
||||
{},
|
||||
);
|
||||
return statusEvent.enabled;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/send-confirm-phone', {
|
||||
@@ -2356,7 +2441,8 @@ export class AuthController extends PuterController {
|
||||
let result;
|
||||
try {
|
||||
result = await this.clients.db.write(
|
||||
'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
|
||||
'UPDATE `user` SET `password` = ?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL, ' +
|
||||
'`requires_password_change` = 0, `temp_password_expires_at` = NULL WHERE `id` = ? AND `pass_recovery_token` = ?',
|
||||
[password_hash, user.id, decoded.token],
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -2384,6 +2470,13 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
// Best effort: the password is already committed, and an audit write
|
||||
// must not skip the eviction below.
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id as number)
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
// A password reset is the "I think someone else has access" flow —
|
||||
// evict every interactive session so a hijacked one doesn't survive.
|
||||
@@ -2421,11 +2514,20 @@ export class AuthController extends PuterController {
|
||||
const user = req.userProtected!.user;
|
||||
|
||||
const password_hash = await bcrypt.hash(new_pass, 8);
|
||||
// Clearing the forced-change gate is what lets a team seat back
|
||||
// in; nothing else writes these two columns to their cleared state.
|
||||
await this.stores.user.update(user.id, {
|
||||
password: password_hash,
|
||||
pass_recovery_token: null,
|
||||
change_email_confirm_token: null,
|
||||
requires_password_change: 0,
|
||||
temp_password_expires_at: null,
|
||||
});
|
||||
await this.services.team
|
||||
.recordPasswordSelfChange(user.id)
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[team] password self-change audit failed:', err);
|
||||
});
|
||||
|
||||
// Sign out every other web session (cascading to their derived
|
||||
// rows); only the session that changed the password survives.
|
||||
@@ -3146,14 +3248,108 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a granted `fs:` path permission needs its target created before
|
||||
* the grant can resolve, and if so where and what kind. Null when there is
|
||||
* nothing to create: `create` was not requested, the entry does not name an
|
||||
* `fs:` path, it is a `manage:` grant, or the path already exists.
|
||||
*
|
||||
* Side-effect-free, like `#prepareAppDataGrant` above — a pre-grant hook
|
||||
* run in the same validation loop, so a bad entry elsewhere in the list
|
||||
* still leaves nothing created.
|
||||
*/
|
||||
async #planFsPathCreate(
|
||||
actor: Actor,
|
||||
permission: string,
|
||||
create: boolean | FsCreateKind,
|
||||
): Promise<{ path: string; kind: FsCreateKind } | null> {
|
||||
if (!create) return null;
|
||||
const parsed = parseFsPathPermission(permission);
|
||||
if (!parsed) return null;
|
||||
// A `manage:` grant delegates re-sharing, not access itself — creating
|
||||
// a path in order to delegate management of it is not a coherent
|
||||
// request.
|
||||
if (parsed.hasManage) return null;
|
||||
|
||||
const path = normalizeAbsolutePath(parsed.path);
|
||||
const existing = await this.stores.fsEntry.getEntryByPath(path);
|
||||
if (existing) {
|
||||
if (
|
||||
(create === 'dir' && !existing.isDir) ||
|
||||
(create === 'file' && existing.isDir)
|
||||
) {
|
||||
throw new HttpError(409, `An entry already exists at ${path}`, {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
}
|
||||
// Already there: the grant resolves the existing entry, nothing to
|
||||
// create. A `true` heuristic guess never conflicts with reality.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Checked only once the path is confirmed missing, so an existing
|
||||
// shared path outside the caller's home is not a new rejection.
|
||||
assertCreatablePath(path, actor.user!.username!);
|
||||
|
||||
const kind =
|
||||
create === true
|
||||
? fsCreateKindFor(pathPosix.basename(path))
|
||||
: create;
|
||||
return { path, kind };
|
||||
}
|
||||
|
||||
/** Creates the target `#planFsPathCreate` planned, as the user actor. */
|
||||
async #createGrantTarget(
|
||||
actor: Actor,
|
||||
plan: { path: string; kind: FsCreateKind },
|
||||
): Promise<FSEntry> {
|
||||
const userId = actor.user!.id;
|
||||
// Missing intermediate directories are created along with the leaf.
|
||||
// They are deliberately not rolled back if a later phase fails, so a
|
||||
// failed grant can leave empty directories inside the user's home.
|
||||
return plan.kind === 'dir'
|
||||
? await this.services.fs.mkdir(userId, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
})
|
||||
: await this.services.fs.touch(userId, {
|
||||
path: plan.path,
|
||||
createMissingParents: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes what `#createGrantTarget` made for this request, newest first,
|
||||
* when a later phase fails. Auto-created intermediate directories are not
|
||||
* rolled back — they are left as empty directories inside the user's own
|
||||
* home.
|
||||
*/
|
||||
async #rollbackCreatedGrantTargets(
|
||||
actor: Actor,
|
||||
created: FSEntry[],
|
||||
): Promise<void> {
|
||||
const userId = actor.user!.id;
|
||||
for (const entry of [...created].reverse()) {
|
||||
try {
|
||||
await this.services.fs.remove(userId, {
|
||||
entry,
|
||||
recursive: false,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort: the error that triggered the rollback is what
|
||||
// the caller sees, not a failure to undo it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/auth/grant-user-app', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, permissions, extra, meta } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, permissions, extra, meta } = req.body ?? {};
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
@@ -3170,26 +3366,73 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// 400 on a bad value, before anything else runs.
|
||||
const create = parseCreateFlag(req.body?.create);
|
||||
|
||||
// Validate every entry before writing any, so a bad one in the list
|
||||
// cannot leave a partially-granted set behind: the dialog reads a 4xx as
|
||||
// "nothing was written" and skips its withdrawal, so a partial commit
|
||||
// leaves live access the user was told they refused. The rewrite running
|
||||
// twice is cheaper than splitting the grant into prepare/commit.
|
||||
// Validate every entry before writing or creating anything, so a bad
|
||||
// one in the list cannot leave a partially-granted set behind: the
|
||||
// dialog reads a 4xx as "nothing was written" and skips its
|
||||
// withdrawal, so a partial commit leaves live access the user was
|
||||
// told they refused.
|
||||
const plans: Array<{
|
||||
entry: string;
|
||||
path: string;
|
||||
kind: FsCreateKind;
|
||||
}> = [];
|
||||
for (const entry of list) {
|
||||
await this.services.permission.assertUserAppPermissionWritable(
|
||||
entry,
|
||||
);
|
||||
// A delegation over a whole key-value namespace has no bounded
|
||||
// description, so it is refused where it is asked for rather than
|
||||
// prompted for and then refused at use.
|
||||
assertBoundedManageGrant(entry);
|
||||
await this.#prepareAppDataGrant(req.actor!, entry);
|
||||
}
|
||||
for (const entry of list) {
|
||||
await this.services.permission.grantUserAppPermission(
|
||||
const plan = await this.#planFsPathCreate(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
entry,
|
||||
extra ?? undefined,
|
||||
meta ?? undefined,
|
||||
create,
|
||||
);
|
||||
if (plan) {
|
||||
// Checked on every planned entry, not just successful
|
||||
// creations, so the cap bounds attempts in this request.
|
||||
if (plans.length >= MAX_CREATED_ENTRIES_PER_GRANT) {
|
||||
throw new HttpError(400, 'Too many paths to create', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
plans.push({ entry, ...plan });
|
||||
} else {
|
||||
// Nothing to create for this entry: the same pre-flight the
|
||||
// rewrite always ran, measuring the width of the row it
|
||||
// resolves to. Once a path is created, `grantUserAppPermission`
|
||||
// re-runs the identical rewrite and check itself.
|
||||
await this.services.permission.assertUserAppPermissionWritable(
|
||||
entry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Two entries in the same batch can name the same path (different
|
||||
// modes on one `fs:` target); create it once, not once per entry.
|
||||
const uniquePlans = [
|
||||
...new Map(plans.map((p) => [p.path, p])).values(),
|
||||
];
|
||||
|
||||
const created: FSEntry[] = [];
|
||||
try {
|
||||
for (const plan of uniquePlans) {
|
||||
created.push(await this.#createGrantTarget(req.actor!, plan));
|
||||
}
|
||||
for (const entry of list) {
|
||||
await this.services.permission.grantUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
entry,
|
||||
extra ?? undefined,
|
||||
meta ?? undefined,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await this.#rollbackCreatedGrantTargets(req.actor!, created);
|
||||
throw err;
|
||||
}
|
||||
res.json({});
|
||||
}
|
||||
@@ -3197,9 +3440,8 @@ export class AuthController extends PuterController {
|
||||
// -- Permission revokes ------------------------------------------
|
||||
|
||||
/**
|
||||
* @deprecated Use `puter.fs.unshare()`, which withdraws the share row and
|
||||
* the grant together. Kept for direct HTTP callers: the grant side is
|
||||
* retired, but access it left behind has to stay withdrawable.
|
||||
* @deprecated Use `puter.fs.unshare()`. Kept for direct HTTP callers:
|
||||
* access left behind by an older client has to stay withdrawable.
|
||||
*/
|
||||
@Post('/auth/revoke-user-user', {
|
||||
subdomain: 'api',
|
||||
@@ -3207,7 +3449,7 @@ export class AuthController extends PuterController {
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeUserUser(req: Request, res: Response): Promise<void> {
|
||||
const { target_username, permission, meta } = req.body;
|
||||
const { target_username, permission, meta } = req.body ?? {};
|
||||
if (!target_username || !permission) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
@@ -3221,6 +3463,13 @@ export class AuthController extends PuterController {
|
||||
permission,
|
||||
meta,
|
||||
);
|
||||
// The share index is what listings read, so it has to go with the
|
||||
// grant — otherwise the item keeps reporting itself as shared.
|
||||
await this.services.share.onGrantRevoked(
|
||||
req.actor!,
|
||||
target_username,
|
||||
permission,
|
||||
);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
@@ -3230,8 +3479,8 @@ export class AuthController extends PuterController {
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, permissions, meta } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, permissions, meta } = req.body ?? {};
|
||||
this.#validateAppPermissionParams({
|
||||
app_uid,
|
||||
origin,
|
||||
@@ -3276,7 +3525,7 @@ export class AuthController extends PuterController {
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
||||
const { permissions } = req.body;
|
||||
const { permissions } = req.body ?? {};
|
||||
if (!Array.isArray(permissions)) {
|
||||
throw new HttpError(400, 'Missing or invalid `permissions` array', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -3317,7 +3566,7 @@ export class AuthController extends PuterController {
|
||||
// mandatory: an access token must not be able to revoke its own
|
||||
// issuing web session.
|
||||
async handleRevokeSession(req: Request, res: Response): Promise<void> {
|
||||
const { uuid } = req.body;
|
||||
const { uuid } = req.body ?? {};
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `uuid`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -3394,8 +3643,8 @@ export class AuthController extends PuterController {
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleGrantDevApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, extra, meta } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, extra, meta } = req.body ?? {};
|
||||
if (origin && !app_uid) {
|
||||
// Registered apps only, for the same reason the user-app handlers
|
||||
// insist on it: a synthesised `app-<uuidv5(origin)>` is resolved
|
||||
@@ -3428,8 +3677,8 @@ export class AuthController extends PuterController {
|
||||
rateLimit: GRANT_LIMIT,
|
||||
})
|
||||
async handleRevokeDevApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin, permission, meta } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin, permission, meta } = req.body ?? {};
|
||||
if (origin && !app_uid) {
|
||||
// Registered apps only — see handleGrantDevApp.
|
||||
app_uid = await this.#registeredAppUidFromOrigin(origin);
|
||||
@@ -3513,14 +3762,21 @@ export class AuthController extends PuterController {
|
||||
? JSON.parse(r.extra)
|
||||
: (r.extra ?? {}),
|
||||
})),
|
||||
user_to_myself: (userPermsIn as Row[]).map((r) => ({
|
||||
user: r.username,
|
||||
permission: r.permission,
|
||||
extra:
|
||||
typeof r.extra === 'string'
|
||||
? JSON.parse(r.extra)
|
||||
: (r.extra ?? {}),
|
||||
})),
|
||||
// A key-value share grant names its owner's uuid and the exact
|
||||
// key prefix it covers. The handle is the only name its holder is
|
||||
// meant to have for that region, so the grant behind it is not
|
||||
// part of what they are shown. The owner still sees their own side
|
||||
// above, and in `GET /events/kv-handles`.
|
||||
user_to_myself: (userPermsIn as Row[])
|
||||
.filter((r) => !isKvSharePermission(r.permission))
|
||||
.map((r) => ({
|
||||
user: r.username,
|
||||
permission: r.permission,
|
||||
extra:
|
||||
typeof r.extra === 'string'
|
||||
? JSON.parse(r.extra)
|
||||
: (r.extra ?? {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3551,8 +3807,8 @@ export class AuthController extends PuterController {
|
||||
rateLimit: { ...AUTH_CHECK_LIMIT, scope: 'app-token', limit: 120 },
|
||||
})
|
||||
async handleGetUserAppToken(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin } = req.body ?? {};
|
||||
const resolvedFromOrigin = !app_uid && !!origin;
|
||||
if (!app_uid && origin) {
|
||||
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
||||
@@ -3673,8 +3929,8 @@ export class AuthController extends PuterController {
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckApp(req: Request, res: Response): Promise<void> {
|
||||
let { app_uid } = req.body;
|
||||
const { origin } = req.body;
|
||||
let { app_uid } = req.body ?? {};
|
||||
const { origin } = req.body ?? {};
|
||||
if (!app_uid && origin) {
|
||||
app_uid = await this.services.auth.appUidFromOrigin(origin);
|
||||
}
|
||||
@@ -3713,7 +3969,7 @@ export class AuthController extends PuterController {
|
||||
rateLimit: CREDENTIAL_MINT_LIMIT,
|
||||
})
|
||||
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
|
||||
const { permissions, expiresIn, label } = req.body;
|
||||
const { permissions, expiresIn, label } = req.body ?? {};
|
||||
if (!Array.isArray(permissions) || permissions.length === 0) {
|
||||
throw new HttpError(400, 'Missing or empty `permissions` array', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -3732,6 +3988,24 @@ export class AuthController extends PuterController {
|
||||
normalizedLabel = label.trim().slice(0, 64) || null;
|
||||
}
|
||||
|
||||
// Whole seconds or a duration string ('30d'). A wrong type, a
|
||||
// fraction, or an unparseable unit all reach the signer, which throws
|
||||
// after the session row is already inserted.
|
||||
if (expiresIn !== undefined && expiresIn !== null) {
|
||||
const usable =
|
||||
typeof expiresIn === 'number'
|
||||
? Number.isInteger(expiresIn) && expiresIn > 0
|
||||
: typeof expiresIn === 'string' &&
|
||||
/^\d+\s*[smhdwy]?$/.test(expiresIn.trim());
|
||||
if (!usable) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`expiresIn` must be a whole number of seconds or a duration string like `30d`',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
|
||||
const normalized = permissions.map((spec) => {
|
||||
if (typeof spec === 'string') return [spec];
|
||||
@@ -3759,7 +4033,7 @@ export class AuthController extends PuterController {
|
||||
// mandatory: a leaked access token must not be able to silently
|
||||
// revoke its own siblings.
|
||||
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
||||
let { tokenOrUuid } = req.body;
|
||||
let { tokenOrUuid } = req.body ?? {};
|
||||
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
||||
throw new HttpError(400, 'Missing `tokenOrUuid`', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -4165,6 +4439,9 @@ export class AuthController extends PuterController {
|
||||
'/user-protected/change-password',
|
||||
{
|
||||
requireUserActor: true,
|
||||
// The forced-change gate refuses everything else, so this is
|
||||
// the one route an account owing a password change may reach.
|
||||
allowUnconfirmed: true,
|
||||
rateLimit: {
|
||||
scope: 'passwd',
|
||||
limit: 10,
|
||||
@@ -4379,7 +4656,15 @@ export class AuthController extends PuterController {
|
||||
): Promise<UserRow | null> {
|
||||
const existing = await this.stores.user.findEmailOwner(email, opts);
|
||||
if (!existing) return null;
|
||||
if (existing.email_confirmed || existing.password !== null) {
|
||||
// A provisioned account looks exactly like a claimable placeholder --
|
||||
// no password, unconfirmed -- but claiming it hands a stranger that
|
||||
// team's membership.
|
||||
const orgSeat = await this.stores.team.getOrgSeat(existing.id);
|
||||
if (
|
||||
existing.email_confirmed ||
|
||||
existing.password !== null ||
|
||||
orgSeat !== null
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'This email already exists in our database. Please use another one.',
|
||||
@@ -4562,6 +4847,7 @@ export class AuthController extends PuterController {
|
||||
services: this.services,
|
||||
apiBaseUrl: (this.config as { api_base_url?: string })
|
||||
.api_base_url,
|
||||
config: this.config,
|
||||
} as never,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Post } from '../../core/http/decorators.js';
|
||||
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
|
||||
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
/**
|
||||
@@ -55,18 +56,11 @@ export class BroadcastController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerOnce = (name: string): string | undefined => {
|
||||
const value = req.headers[name];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
};
|
||||
|
||||
const result = await broadcast.verifyAndEmit(req.rawBody, req.body, {
|
||||
peerId: headerOnce('x-broadcast-peer-id'),
|
||||
timestamp: headerOnce('x-broadcast-timestamp'),
|
||||
nonce: headerOnce('x-broadcast-nonce'),
|
||||
signature: headerOnce('x-broadcast-signature'),
|
||||
});
|
||||
const result = await broadcast.verifyAndEmit(
|
||||
req.rawBody,
|
||||
req.body,
|
||||
broadcastHeaders(req),
|
||||
);
|
||||
|
||||
if (result.ok) {
|
||||
res.status(200).json({ ok: true, ...(result.info ?? {}) });
|
||||
@@ -76,4 +70,57 @@ export class BroadcastController extends PuterController {
|
||||
error: { message: result.message ?? 'Bad request' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event deliveries one region addressed at this one, on the same signed
|
||||
* channel as the webhook above but carrying socket traffic rather than bus
|
||||
* events. The answer names the pairs this region holds no socket for, which
|
||||
* is what lets the sender correct its presence row.
|
||||
*/
|
||||
@Post('/events', { subdomain: '*' })
|
||||
async events(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 reply = await this.services.eventForward.receive(
|
||||
(req.body ?? {}) as ForwardBatch,
|
||||
);
|
||||
res.status(200).json({ ok: true, ...reply });
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastHeaders = (req: Request) => {
|
||||
const headerOnce = (name: string): string | undefined => {
|
||||
const value = req.headers[name];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
peerId: headerOnce('x-broadcast-peer-id'),
|
||||
timestamp: headerOnce('x-broadcast-timestamp'),
|
||||
nonce: headerOnce('x-broadcast-nonce'),
|
||||
signature: headerOnce('x-broadcast-signature'),
|
||||
};
|
||||
};
|
||||
@@ -263,19 +263,24 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
});
|
||||
|
||||
it('does not attach release listeners when the driver declares no concurrent config', async () => {
|
||||
// The optimisation that lets the existing test stubs in
|
||||
// DriverController.test.ts get away without an EventEmitter-shaped
|
||||
// `res`: skip the once() wiring entirely when there's no spec.
|
||||
// With no spec there is nothing to release, so the gate must not
|
||||
// wire `finish`/`close` release listeners. The client-disconnect
|
||||
// hook is separate and always present: exactly one `close` listener.
|
||||
const driver = makeSyntheticDriver();
|
||||
(driver as { concurrent?: unknown }).concurrent = undefined;
|
||||
const controller = buildController(driver);
|
||||
const handler = captureCallHandler(controller);
|
||||
|
||||
// Bare object with no event-emitter surface — exposes the bug
|
||||
// case where the gate would try to call `res.once`.
|
||||
// Minimal `res` that only records which events get listeners.
|
||||
const listened: string[] = [];
|
||||
const bareRes = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
writableFinished: false,
|
||||
once(event: string) {
|
||||
listened.push(event);
|
||||
return this;
|
||||
},
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
@@ -301,5 +306,6 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
bareRes as unknown as Response,
|
||||
);
|
||||
expect(bareRes.body).toMatchObject({ success: true, result: 'pong' });
|
||||
expect(listened).toEqual(['close']);
|
||||
});
|
||||
});
|
||||
@@ -35,9 +35,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { APIConnectionTimeoutError } from 'openai';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DriverMethodLifecycleEvent } from '../../clients/event/types.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { Context, runWithContext } from '../../core/context.js';
|
||||
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { DriverController } from './DriverController.js';
|
||||
|
||||
@@ -325,6 +326,32 @@ describe('DriverController upstream error translation', () => {
|
||||
expect(err).toBe('a bare string');
|
||||
});
|
||||
|
||||
it('maps an SDK connection timeout, which carries no status, to a 504 upstream_timeout', async () => {
|
||||
const raw = new APIConnectionTimeoutError();
|
||||
const { err } = await callWith(throwing(raw));
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'upstream_timeout',
|
||||
message: 'AI provider timed out',
|
||||
cause: raw,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a fetch timeout that undici wraps in a `fetch failed` TypeError to a 504 upstream_timeout', async () => {
|
||||
const raw = new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('Headers Timeout Error'), {
|
||||
name: 'HeadersTimeoutError',
|
||||
code: 'UND_ERR_HEADERS_TIMEOUT',
|
||||
}),
|
||||
});
|
||||
const { err } = await callWith(throwing(raw));
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 504,
|
||||
legacyCode: 'upstream_timeout',
|
||||
fields: { upstreamCode: 'UND_ERR_HEADERS_TIMEOUT' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an error with a sub-400 status through untranslated', async () => {
|
||||
const raw = { status: 302, message: 'redirected' };
|
||||
const { err } = await callWith(throwing(raw));
|
||||
@@ -435,3 +462,53 @@ describe('DriverController per-method rate limiting', () => {
|
||||
expect(alarms).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Client disconnect --------------------------------------------------
|
||||
|
||||
describe('DriverController client disconnect', () => {
|
||||
it('exposes an abort signal in the request context that fires when the client leaves early', async () => {
|
||||
let seen: AbortSignal | undefined;
|
||||
const { handler } = build({
|
||||
run: async () => {
|
||||
seen = Context.get('abortSignal');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const res = new MockRes();
|
||||
const done = runWithContext({}, () =>
|
||||
handler(
|
||||
makeReq({ interface: 'test-iface', method: 'run' }),
|
||||
res as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
res.destroy();
|
||||
await done;
|
||||
|
||||
expect(seen).toBeInstanceOf(AbortSignal);
|
||||
expect(seen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('does not abort when the response simply finished', async () => {
|
||||
let seen: AbortSignal | undefined;
|
||||
const { handler } = build({
|
||||
run: () => {
|
||||
seen = Context.get('abortSignal');
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
const res = new MockRes();
|
||||
await runWithContext({}, () =>
|
||||
handler(
|
||||
makeReq({ interface: 'test-iface', method: 'run' }),
|
||||
res as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
res.end();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(seen?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { isHttpError } from '../../core/http/HttpError.js';
|
||||
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DriverController } from './DriverController.js';
|
||||
|
||||
// The reputation requirement a driver declares per method, exercised through
|
||||
// `#handleCall` the same way the subscription test does: a synthetic driver
|
||||
// carrying only the field under test, plus a config that says what its tiers
|
||||
// are worth.
|
||||
|
||||
const captureCallHandler = (controller: DriverController): RequestHandler => {
|
||||
let handler: RequestHandler | undefined;
|
||||
const fakeRouter = {
|
||||
post: (path: string, _opts: unknown, h: RequestHandler) => {
|
||||
if (path === '/call') handler = h;
|
||||
return fakeRouter;
|
||||
},
|
||||
get: () => fakeRouter,
|
||||
use: () => fakeRouter,
|
||||
};
|
||||
controller.registerRoutes(
|
||||
fakeRouter as unknown as Parameters<
|
||||
typeof controller.registerRoutes
|
||||
>[0],
|
||||
);
|
||||
if (!handler) throw new Error('failed to capture POST /call handler');
|
||||
return handler;
|
||||
};
|
||||
|
||||
class StubRes extends EventEmitter {
|
||||
statusCode = 200;
|
||||
body: unknown = undefined;
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
json(body: unknown) {
|
||||
this.body = body;
|
||||
return this;
|
||||
}
|
||||
setHeader() {
|
||||
return this;
|
||||
}
|
||||
type() {
|
||||
return this;
|
||||
}
|
||||
send() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const makeReq = (
|
||||
method: string,
|
||||
tag: string,
|
||||
reputation: number | undefined,
|
||||
): Request =>
|
||||
({
|
||||
body: { interface: 'test-iface', method },
|
||||
actor: { user: { uuid: `u-${tag}`, username: 'u', reputation } },
|
||||
headers: { 'user-agent': tag },
|
||||
query: {},
|
||||
ip: '127.0.0.1',
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
}) as unknown as Request;
|
||||
|
||||
const syntheticDriver = {
|
||||
driverInterface: 'test-iface',
|
||||
driverName: 'test-driver',
|
||||
isDefault: true,
|
||||
requireReputation: {
|
||||
methods: { gated: 'standard', strict: 'trusted', ungated: false },
|
||||
},
|
||||
onServerStart() {},
|
||||
onServerPrepareShutdown() {},
|
||||
onServerShutdown() {},
|
||||
open: async () => 'open-ok',
|
||||
ungated: async () => 'ungated-ok',
|
||||
gated: async () => 'gated-ok',
|
||||
strict: async () => 'strict-ok',
|
||||
};
|
||||
|
||||
const buildController = (config: IConfig) =>
|
||||
new DriverController(
|
||||
config,
|
||||
{ alarm: { create: () => {} } } as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ syntheticDriver } as any,
|
||||
);
|
||||
|
||||
const call = (
|
||||
config: IConfig,
|
||||
method: string,
|
||||
tag: string,
|
||||
reputation?: number,
|
||||
) => {
|
||||
const handler = captureCallHandler(buildController(config));
|
||||
const res = new StubRes();
|
||||
return runWithContext({}, () =>
|
||||
handler(
|
||||
makeReq(method, tag, reputation),
|
||||
res as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
).then(() => res);
|
||||
};
|
||||
|
||||
const tiers = {
|
||||
reputationGate: { tiers: { standard: 60, trusted: 90 } },
|
||||
} as unknown as IConfig;
|
||||
|
||||
const expect403 = (promise: Promise<unknown>) =>
|
||||
expect(promise).rejects.toSatisfy(
|
||||
(e) =>
|
||||
isHttpError(e) &&
|
||||
(e as { statusCode: number }).statusCode === 403 &&
|
||||
(e as { legacyCode?: string }).legacyCode === 'reputation_required',
|
||||
);
|
||||
|
||||
describe('DriverController — per-method reputation requirement', () => {
|
||||
beforeEach(() => {
|
||||
configureRateLimit();
|
||||
});
|
||||
|
||||
it('leaves a method that declares nothing open to a low-scoring account', async () => {
|
||||
const res = await call(tiers, 'open', 'open', 10);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'open-ok' });
|
||||
});
|
||||
|
||||
it('leaves a method that opts out open to the same account', async () => {
|
||||
const res = await call(tiers, 'ungated', 'ungated', 10);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'ungated-ok' });
|
||||
});
|
||||
|
||||
it('rejects an account below the tier a method asks for', async () => {
|
||||
await expect403(call(tiers, 'gated', 'low', 30));
|
||||
});
|
||||
|
||||
it('admits an account that clears it', async () => {
|
||||
const res = await call(tiers, 'gated', 'high', 70);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'gated-ok' });
|
||||
});
|
||||
|
||||
it('holds each method to its own tier', async () => {
|
||||
await expect403(call(tiers, 'strict', 'strict-low', 70));
|
||||
const res = await call(tiers, 'strict', 'strict-high', 95);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'strict-ok' });
|
||||
});
|
||||
|
||||
it('enforces nothing on a deployment that defines no tiers', async () => {
|
||||
const res = await call({} as IConfig, 'gated', 'no-tiers', 0);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'gated-ok' });
|
||||
});
|
||||
|
||||
it('enforces nothing once the master switch is off', async () => {
|
||||
const off = {
|
||||
reputationGate: { enabled: false, tiers: { standard: 60 } },
|
||||
} as unknown as IConfig;
|
||||
const res = await call(off, 'gated', 'switched-off', 0);
|
||||
expect(res.body).toMatchObject({ success: true, result: 'gated-ok' });
|
||||
});
|
||||
});
|
||||
@@ -24,18 +24,21 @@ import { Context } from '../../core/context.js';
|
||||
import { Controller } from '../../core/http/decorators.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { assertNotUserSession } from '../../core/http/middleware/gates.js';
|
||||
import { assertActorMeetsReputation } from '../../core/reputation.js';
|
||||
import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
resolveDriverMethodRequireReputation,
|
||||
resolveDriverMethodRequireSubscription,
|
||||
} from '../../drivers/meta.js';
|
||||
import { assertActorHasSubscription } from '../../services/metering/enforcement.js';
|
||||
@@ -114,9 +117,20 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
message?: string;
|
||||
error?: { code?: string; type?: string; message?: string };
|
||||
code?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
if (typeof status !== 'number') return err;
|
||||
if (typeof status !== 'number') {
|
||||
if (isUpstreamTimeoutError(e)) {
|
||||
const cause = e.cause as { code?: string } | undefined;
|
||||
return new HttpError(504, 'AI provider timed out', {
|
||||
legacyCode: 'upstream_timeout',
|
||||
fields: { upstreamCode: e.code ?? cause?.code },
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
@@ -327,6 +341,25 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// Methods that ask for a trusted-enough account. Declared per-driver
|
||||
// (`@Driver({ requireReputation })`) for the same reason the
|
||||
// subscription block is. Checked ahead of the plan and rate-limit
|
||||
// gates, matching the route chain: whether this account should be
|
||||
// reaching the method at all is settled before what it pays for or how
|
||||
// often it may ask. Inert unless the running config gives the named
|
||||
// tier a score.
|
||||
const reputationRequirement = resolveDriverMethodRequireReputation(
|
||||
driverMeta?.requireReputation,
|
||||
method,
|
||||
);
|
||||
if (reputationRequirement !== undefined) {
|
||||
await assertActorMeetsReputation(
|
||||
req.actor,
|
||||
reputationRequirement,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
// Subscriber-only methods. Declared per-driver
|
||||
// (`@Driver({ requireSubscription })`) because `/drivers/call` is a
|
||||
// single route and a route option would apply to every driver at once.
|
||||
@@ -412,6 +445,15 @@ export class DriverController extends PuterController {
|
||||
// a stale value from a prior call.
|
||||
Context.set('driverName', requestedDriver);
|
||||
|
||||
// A caller that hangs up mid-call gets nothing back, so long-running
|
||||
// drivers watch this to stop working (and metering) as soon as it does.
|
||||
// `close` after `finish` is the normal end of a response, not an abort.
|
||||
const abort = new AbortController();
|
||||
res.once('close', () => {
|
||||
if (!res.writableFinished) abort.abort();
|
||||
});
|
||||
Context.set('abortSignal', abort.signal);
|
||||
|
||||
// Per-method lifecycle events, scoped to `driver.<iface>.<method>`.
|
||||
// Subscribers can listen on `driver.*`, `driver.<iface>.*`, or the
|
||||
// exact key. `before` is emitted via `emitAndWait` so a listener may
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* 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 { timingSafeEqual } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.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 { normalizeLimit } from '../../util/pagination.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
|
||||
import {
|
||||
EVENTS_FETCH_LIMIT,
|
||||
EVENTS_FETCH_LIMIT_CAP,
|
||||
EVENTS_HANDLER_LIST_LIMIT,
|
||||
EVENTS_LIST_LIMIT,
|
||||
EVENTS_WORKER_LIST_LIMIT,
|
||||
} from './limits.js';
|
||||
import {
|
||||
EventsWorkerDeployer,
|
||||
LocalEventsInvokeTransport,
|
||||
} from './workerDeploy.js';
|
||||
|
||||
/**
|
||||
* The durable half of the events surface. Session subscriptions arrive over the
|
||||
* socket that holds them and are not routable; these are rows that outlive
|
||||
* every connection, so they need somewhere to be created, listed and revoked
|
||||
* from without one.
|
||||
*
|
||||
* Gates are `subdomain: 'api'` (verb routes are root-origin by default) plus
|
||||
* `allowAccessToken`, because API tokens are in the scoping matrix and access
|
||||
* tokens are refused on authenticated routes otherwise. Deliberately **not**
|
||||
* `allowedAppIds`: it does not restrict a route to app actors, and using it as
|
||||
* though it did is the mis-gating this surface has to avoid. Scope is decided
|
||||
* from `effectiveApp` inside the service, over the index the rows are stored
|
||||
* under.
|
||||
*/
|
||||
/** What a dispatcher may ask to have deployed: an events script name. */
|
||||
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}$/;
|
||||
|
||||
@Controller('/events')
|
||||
export class EventsController extends PuterController {
|
||||
/**
|
||||
* POST /events/subscribe — register a subscription that outlives the
|
||||
* caller.
|
||||
*/
|
||||
@Post('/subscribe', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async subscribe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const { sub } = await this.services.events.subscribeDurable(
|
||||
actor,
|
||||
this.#body(req),
|
||||
);
|
||||
res.json(sub);
|
||||
}
|
||||
|
||||
/** GET /events/subscriptions — what the caller holds, one page at a time. */
|
||||
@Get('/subscriptions', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async list(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listDurable(actor, {
|
||||
limit: normalizeLimit(query.limit, { cap: DURABLE_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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/fetch — what the caller missed, one page at a time.
|
||||
*
|
||||
* Stateless: no subscription, no stored position, nothing written. The
|
||||
* cursor comes back to the caller and goes out again as `after`.
|
||||
*/
|
||||
@Get('/fetch', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_FETCH_LIMIT,
|
||||
})
|
||||
async fetch(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.fetchMissed(actor, {
|
||||
subject: typeof query.subject === 'string' ? query.subject : '',
|
||||
after: typeof query.after === 'string' ? query.after : undefined,
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: EVENTS_FETCH_LIMIT_CAP,
|
||||
}),
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/unsubscribe — an id the caller does not hold reads as
|
||||
* absent.
|
||||
*/
|
||||
@Post('/unsubscribe', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async unsubscribe(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
await this.services.events.unsubscribeDurable(actor, this.#body(req));
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// -- Cross-user key-value handles --------------------------------
|
||||
|
||||
/**
|
||||
* POST /events/kv-handles — hand another user a watchable region of this
|
||||
* account's key-value data.
|
||||
*
|
||||
* An account session, or an app session holding a `manage:` delegation on
|
||||
* the region — the service tells those apart and gates each rather than the
|
||||
* route doing it, because `effectiveApp` is where app-ness actually lives.
|
||||
* A token an app minted is refused there too: a delegation is the app's to
|
||||
* hold, not to pass on. A user's own token still acts for the user.
|
||||
*/
|
||||
@Post('/kv-handles', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async mintKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.mintKvHandle(actor, this.#body(req)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/kv-handles — what this account has shared out. An app sees
|
||||
* its own namespace only.
|
||||
*/
|
||||
@Get('/kv-handles', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_LIST_LIMIT,
|
||||
})
|
||||
async listKvHandles(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listKvHandles(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: KV_HANDLE_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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /events/kv-handles/:handle — take a shared region back. An app may
|
||||
* take back one in its own namespace; a handle another account minted reads
|
||||
* as absent.
|
||||
*/
|
||||
@Delete('/kv-handles/:handle', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async revokeKvHandle(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.revokeKvHandle(actor, req.params.handle),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Handlers ----------------------------------------------------
|
||||
//
|
||||
// Deploying an app's code, so the gate is the same as the verbs above plus
|
||||
// the ownership check the service makes: the app token's own app, or an app
|
||||
// a user session names and that user owns.
|
||||
|
||||
/** POST /events/handlers/publish — create or update one named handler. */
|
||||
@Post('/handlers/publish', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async publishHandler(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.publishHandler(actor, this.#body(req)),
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /events/handlers/publishAll — a build step's whole set, in order. */
|
||||
@Post('/handlers/publishAll', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async publishHandlers(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json({
|
||||
handlers: await this.services.events.publishHandlers(
|
||||
actor,
|
||||
this.#body(req),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /events/handlers/list — names and hashes, never source. */
|
||||
@Get('/handlers/list', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_HANDLER_LIST_LIMIT,
|
||||
})
|
||||
async listHandlers(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
res.json({
|
||||
handlers: await this.services.events.listHandlers(actor, {
|
||||
appUid:
|
||||
typeof query.appUid === 'string' ? query.appUid : undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /events/handlers/remove — delete, and suspend what was bound. */
|
||||
@Post('/handlers/remove', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async removeHandler(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.removeHandler(actor, this.#body(req)),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Events workers ------------------------------------------------
|
||||
//
|
||||
// The billable artifact a published handler set implies. Account-scoped
|
||||
// like the kv-handle surface above: this is the owner's own view of what
|
||||
// it is paying for, so — unlike the handler routes — an app token cannot
|
||||
// act here on its owner's behalf.
|
||||
|
||||
/** GET /events/workers — the caller's own events workers, one per app. */
|
||||
@Get('/workers', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: EVENTS_WORKER_LIST_LIMIT,
|
||||
})
|
||||
async listEventsWorkers(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
const query = (req.query ?? {}) as Record<string, unknown>;
|
||||
|
||||
const page = await this.services.events.listEventsWorkers(actor, {
|
||||
limit: normalizeLimit(query.limit, {
|
||||
cap: EVENTS_WORKERS_LIST_LIMIT_CAP,
|
||||
}),
|
||||
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: page.items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
deployable: page.deployable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/workers/destroy — remove every handler an app has published,
|
||||
* taking its events worker down with the last one.
|
||||
*/
|
||||
@Post('/workers/destroy', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowAccessToken: true,
|
||||
})
|
||||
async destroyEventsWorker(req: Request, res: Response): Promise<void> {
|
||||
const actor = this.#requireActor(req);
|
||||
res.json(
|
||||
await this.services.events.destroyEventsWorker(
|
||||
actor,
|
||||
this.#body(req),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /events/worker/rehydrate — the events dispatcher asking for a script
|
||||
* it could not find in its namespace, which is also how a handler set is
|
||||
* deployed the first time. Internal: the shared secret is the only gate,
|
||||
* and there is no actor.
|
||||
*/
|
||||
@Post('/worker/rehydrate', {
|
||||
subdomain: 'api',
|
||||
// The shared secret is what gates this route, so the limit is only a
|
||||
// guessing-rate bound — hence per-IP and deliberately high. The caller
|
||||
// fans in from every edge location behind a small set of egress
|
||||
// addresses and asks once per missing script per isolate.
|
||||
rateLimit: {
|
||||
scope: 'events-worker-rehydrate',
|
||||
limit: 6000,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
})
|
||||
async rehydrateWorker(req: Request, res: Response): Promise<void> {
|
||||
this.#requireInternalAuth(req);
|
||||
const body = this.#body(req);
|
||||
const script = String(body.script ?? '');
|
||||
const appUid = String(body.appUid ?? '');
|
||||
if (!EVENTS_SCRIPT_REGEX.test(script) || !APP_UID_REGEX.test(appUid))
|
||||
throw new HttpError(400, 'Missing or invalid `script`/`appUid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) {
|
||||
res.status(404).json({ deployed: false, reason: 'disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await deployer.ensure(appUid, 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.
|
||||
res.status(
|
||||
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
|
||||
).json({
|
||||
deployed: false,
|
||||
reason: outcome,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local development has no events dispatcher, so invocations are handed
|
||||
* straight to the local worker runtime — including the deploy-on-miss the
|
||||
* dispatcher would otherwise ask for over the rehydrate route above.
|
||||
*
|
||||
* Elsewhere, this backend already knows the app and script a miss names, so
|
||||
* it deploys the script itself rather than count on the rehydrate callback
|
||||
* landing on a backend behind the dispatcher's own hostname.
|
||||
*/
|
||||
override onServerStart(): void {
|
||||
const deployer = this.#eventsWorkerDeployer();
|
||||
if (!deployer.enabled) return;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
#deployer: EventsWorkerDeployer | null = null;
|
||||
|
||||
#eventsWorkerDeployer(): EventsWorkerDeployer {
|
||||
this.#deployer ??= new EventsWorkerDeployer({
|
||||
config: this.config,
|
||||
clients: this.clients,
|
||||
stores: this.stores,
|
||||
services: this.services,
|
||||
drivers: this.drivers,
|
||||
});
|
||||
return this.#deployer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time, so the secret cannot be recovered a byte at a time.
|
||||
* Compares Buffer byte lengths, not string lengths — a string length
|
||||
* compares UTF-16 code units, which can differ from the byte length
|
||||
* `timingSafeEqual` actually requires to match.
|
||||
*/
|
||||
#requireInternalAuth(req: Request): void {
|
||||
const expected = Buffer.from(this.config.events?.internalSecret ?? '');
|
||||
const offered = Buffer.from(
|
||||
String(req.headers['x-puter-internal-auth'] ?? ''),
|
||||
);
|
||||
const ok =
|
||||
expected.length > 0 &&
|
||||
expected.length === offered.length &&
|
||||
timingSafeEqual(expected, offered);
|
||||
if (!ok)
|
||||
throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
|
||||
}
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
if (!actor?.user)
|
||||
throw new HttpError(401, 'Unauthorized', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
#body(req: Request): Record<string, unknown> {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body))
|
||||
throw new HttpError(400, 'body must be an object', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 event limits, and the page that publishes them. An undisclosed limit is
|
||||
* one a developer meets as a service failure, so the numbers here and the
|
||||
* numbers on the page are held against each other.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import {
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
limitFor,
|
||||
type TieredLimit,
|
||||
} from './limits.js';
|
||||
|
||||
const tiers: Array<[string, TieredLimit]> = [
|
||||
['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('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))
|
||||
expect(n).toBeLessThanOrEqual(tier.limit);
|
||||
});
|
||||
|
||||
it.each(tiers)('%s gives a temporary account none', (_name, tier) => {
|
||||
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps what one app may take below what the account may hold', () => {
|
||||
for (const plan of [
|
||||
null,
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
]) {
|
||||
expect(
|
||||
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP, plan),
|
||||
).toBeLessThanOrEqual(
|
||||
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, plan),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps what one app may hold below what the account may hold, for share handles', () => {
|
||||
for (const plan of [
|
||||
null,
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
]) {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, plan),
|
||||
).toBeLessThanOrEqual(limitFor(EVENTS_KV_HANDLES_PER_USER, plan));
|
||||
}
|
||||
});
|
||||
|
||||
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, null)).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads its structural maximum off the paid cap', () => {
|
||||
expect(EVENTS_DURABLE_SUBSCRIPTIONS_MAX).toBe(
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the delivery budgets', () => {
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
|
||||
);
|
||||
expect(EVENTS_WORKER_INVOCATION_LIMIT.limit).toBeLessThan(
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT.limit,
|
||||
);
|
||||
});
|
||||
|
||||
it('pins an explicit scope on each, so two call sites share one counter', () => {
|
||||
for (const spec of [
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
]) {
|
||||
expect(spec.scope).toBeTruthy();
|
||||
expect(spec.window).toBe(60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* 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 { RouteRateLimit } from '../../core/http/types';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
|
||||
// -- Shared event limits ---------------------------------------------
|
||||
//
|
||||
// One cheap write can fan out into (matched subscriptions × connected
|
||||
// sockets), and every number here caps a term of that product. Storage quota
|
||||
// and the FS write limits bound how many events can be *produced*; nothing
|
||||
// else bounds how many deliveries one of them turns into.
|
||||
//
|
||||
// The verbs arrive over the socket rather than over HTTP, so these are not
|
||||
// mounted as route gates — they are spent imperatively at the point each one
|
||||
// protects, with the `scope` naming the counter the same way a route gate
|
||||
// would. Every number below is published in `rate-limits-and-quotas.md`.
|
||||
|
||||
/** Per-user sliding window, spent imperatively via `checkRateLimit`. */
|
||||
const userWindow = (
|
||||
scope: string,
|
||||
limit: number,
|
||||
window = 60_000,
|
||||
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
|
||||
|
||||
/**
|
||||
* A cap that varies by plan, in the shape route gates already declare theirs
|
||||
* in: the base is what a subscribed account sees, and `bySubscription` carves
|
||||
* the free tiers out beneath it. A plan nobody enumerated falls through to the
|
||||
* base, so a new one is generous rather than accidentally throttled.
|
||||
*/
|
||||
export interface TieredLimit {
|
||||
limit: number;
|
||||
bySubscription: Record<string, number>;
|
||||
}
|
||||
|
||||
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
|
||||
limit: paid,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: free,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: temp,
|
||||
},
|
||||
});
|
||||
|
||||
/** The cap one plan sees. An unresolved plan is held to the base. */
|
||||
export const limitFor = (
|
||||
tier: TieredLimit,
|
||||
subscriptionId: string | null,
|
||||
): number =>
|
||||
(subscriptionId === null
|
||||
? undefined
|
||||
: tier.bySubscription[subscriptionId]) ?? tier.limit;
|
||||
|
||||
/**
|
||||
* What a plan-tiered quota resolves to: what an account may hold, and what one
|
||||
* app may take of it.
|
||||
*/
|
||||
export interface TieredQuota {
|
||||
perUser: number;
|
||||
perApp: number;
|
||||
}
|
||||
|
||||
// -- Subscription surface --------------------------------------------
|
||||
|
||||
/**
|
||||
* Live subscriptions one socket may hold.
|
||||
*
|
||||
* Session subscriptions are Redis set members keyed to a socket that can go
|
||||
* away without saying so, so the cap is really on how much a disconnect can
|
||||
* leave behind. A client watching more than a few dozen distinct anchors wants
|
||||
* one subscription on their common parent instead.
|
||||
*/
|
||||
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
|
||||
|
||||
/**
|
||||
* Durable subscriptions one account may hold, across every app.
|
||||
*
|
||||
* These are table rows that keep costing after the client that made them is
|
||||
* gone — a delivery each time their anchor changes, and a cache entry in every
|
||||
* region that sees a write. Counted over the holder index. A temporary account
|
||||
* holds none: nothing outlives its connection to deliver to.
|
||||
*/
|
||||
export const EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER = tiered(500, 100, 0);
|
||||
|
||||
/**
|
||||
* Durable subscriptions one app may hold for one account.
|
||||
*
|
||||
* Below the per-account cap so that one app cannot spend an account's whole
|
||||
* budget: the account-wide number is what the watched-token set costs, and this
|
||||
* is what any single app may take of it.
|
||||
*/
|
||||
export const EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP = tiered(100, 25, 0);
|
||||
|
||||
/** Most rows any account can hold, whatever its plan — a read bound, not a gate. */
|
||||
export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit;
|
||||
|
||||
/**
|
||||
* How long a suspended durable subscription is kept before it is deleted.
|
||||
*
|
||||
* A suspension caused by a revoked grant never resumes — consent to watch is
|
||||
* re-established by subscribing again — so the row survives only long enough
|
||||
* for its holder to see, in `list`, that it stopped and why.
|
||||
*/
|
||||
export const SUSPENDED_ROW_TTL_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Subscribe + unsubscribe calls per minute, per user.
|
||||
*
|
||||
* Both resolve a path and take a write, so a loop over them is a write loop.
|
||||
* Sized like the sharing verbs, which are the closest existing analogue: a
|
||||
* client sets its subscriptions up once and then leaves them alone.
|
||||
*/
|
||||
export const EVENTS_SUBSCRIBE_LIMIT = userWindow('events:subscribe', 60);
|
||||
|
||||
/**
|
||||
* Subscription listings per minute, per user. Reads an index and returns one
|
||||
* page, so it is budgeted well above the verbs that write.
|
||||
*/
|
||||
export const EVENTS_LIST_LIMIT = userWindow('events:list', 120);
|
||||
|
||||
/**
|
||||
* Delivery acknowledgements per minute, per user.
|
||||
*
|
||||
* One ack per `single` delivery, so this sits level with what one subscription
|
||||
* may be delivered — a client acking faster than that is acking things it was
|
||||
* never sent.
|
||||
*/
|
||||
export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
|
||||
|
||||
/**
|
||||
* Share-handle mint + revoke calls per minute, per user.
|
||||
*
|
||||
* Each one issues or withdraws a grant and settles what stood on it, so it is
|
||||
* budgeted with the subscribe verbs rather than the listings.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
|
||||
/**
|
||||
* Live share handles one account may hold out at a time, across every app.
|
||||
*
|
||||
* Each is a standing grant on part of the account's data, and revoking marks
|
||||
* rather than deletes, so without a ceiling the rate limit alone lets the rows
|
||||
* grow forever. A temporary account holds none: the grant outlives the session
|
||||
* that made it, and no account is left to take it back.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
*
|
||||
* Below the per-account cap so that one app cannot spend an account's whole
|
||||
* budget: a handle names one namespace, so without this a second app on the
|
||||
* same account would be left with nothing.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Named handlers one app may have published.
|
||||
*
|
||||
* A handler is a row read on the delivery path, so the cap is on how many
|
||||
* distinct pieces of code an app asks the system to keep addressable — not on
|
||||
* how often it changes them. An app past this is describing events by name
|
||||
* where a `match` filter belongs.
|
||||
*/
|
||||
export const EVENTS_HANDLERS_PER_APP = 100;
|
||||
|
||||
/** Longest a serialized handler may be. */
|
||||
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Longest an app's generated events worker may be, all handlers combined. Above
|
||||
* this a publish is refused with `events_worker_too_large` before it lands, and
|
||||
* a set that got here some other way fails its deploy instead of shipping a
|
||||
* script too big for the runtime to load.
|
||||
*/
|
||||
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Deploys one app's events worker may trigger per hour.
|
||||
*
|
||||
* A deploy provisions upstream, so a set that flaps — publish, remove,
|
||||
* republish — must not turn into an unbounded provisioning loop. Rehydration (a
|
||||
* namespace miss) counts the same as a publish-triggered deploy.
|
||||
*/
|
||||
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const EVENTS_HANDLER_PUBLISH_BATCH = 50;
|
||||
|
||||
/**
|
||||
* Handler publishes and removals per minute, per user.
|
||||
*
|
||||
* A build step publishes its whole set in one call and a developer iterating
|
||||
* publishes a handful; level with the subscribe budget, which is the closest
|
||||
* analogue — a write a client makes deliberately, never in a loop.
|
||||
*/
|
||||
export const EVENTS_HANDLER_PUBLISH_LIMIT = userWindow(
|
||||
'events:handlers:publish',
|
||||
60,
|
||||
);
|
||||
|
||||
/**
|
||||
* Missed-event fetches per minute, per user.
|
||||
*
|
||||
* A keyset page off one index, so it is budgeted like the other listings — a
|
||||
* client catching up after a disconnect walks a handful of pages once, not a
|
||||
* page per event.
|
||||
*/
|
||||
export const EVENTS_FETCH_LIMIT = userWindow('events:fetch', 120);
|
||||
|
||||
/** Rows one fetch page may carry. Defaults to a quarter of it. */
|
||||
export const EVENTS_FETCH_LIMIT_CAP = 200;
|
||||
export const EVENTS_FETCH_LIMIT_DEFAULT = 50;
|
||||
|
||||
/** Handler listings per minute, per user. Reads an index, so budgeted higher. */
|
||||
export const EVENTS_HANDLER_LIST_LIMIT = userWindow(
|
||||
'events:handlers:list',
|
||||
120,
|
||||
);
|
||||
|
||||
/**
|
||||
* Events worker listings per minute, per user. Same shape as the handler
|
||||
* listing.
|
||||
*/
|
||||
export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
|
||||
|
||||
// -- Dispatch fan-out ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscriptions one event may deliver to before dispatch stops and reports a
|
||||
* gap. The amplification ceiling: without it, one write costs as many
|
||||
* deliveries as an account cared to register.
|
||||
*/
|
||||
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
|
||||
|
||||
/**
|
||||
* Broadcast deliveries per minute, per subscription.
|
||||
*
|
||||
* Ten a second sustained is far past what a UI can render and well past what a
|
||||
* human is watching for; a subscription over it is looping, and the gap marker
|
||||
* tells it so rather than letting it silently miss events.
|
||||
*/
|
||||
export const EVENTS_BROADCAST_DELIVERY_LIMIT = userWindow(
|
||||
'events:delivery',
|
||||
600,
|
||||
);
|
||||
|
||||
/**
|
||||
* `single` deliveries per minute, per subscription.
|
||||
*
|
||||
* A fifth of the broadcast budget: each one is leased, acknowledged and may run
|
||||
* an app's handler, so it costs an order of magnitude more than a socket copy.
|
||||
* Over it the event is not queued — a gap marker takes its place, so the
|
||||
* consumer learns it fell behind instead of inheriting a backlog it can never
|
||||
* work through.
|
||||
*/
|
||||
export const EVENTS_SINGLE_DELIVERY_LIMIT = userWindow(
|
||||
'events:delivery:single',
|
||||
120,
|
||||
);
|
||||
|
||||
/**
|
||||
* Handler invocations per minute, per (account, app).
|
||||
*
|
||||
* The one term of the fan-out product that costs real compute, so it is capped
|
||||
* per app rather than per subscription — an app cannot widen it by holding more
|
||||
* subscriptions. A delivery that arrives over the budget is not failed: it
|
||||
* stays owed, and its lease is the backoff.
|
||||
*/
|
||||
export const EVENTS_WORKER_INVOCATION_LIMIT = userWindow(
|
||||
'events:worker:invoke',
|
||||
60,
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter evaluations one event may spend. Lives with the matcher because the
|
||||
* primitive that enforces it does; re-exported here so every published number
|
||||
* is readable in one place.
|
||||
*/
|
||||
export { FILTER_EVALUATIONS_PER_EVENT } from '../../services/events/matcher.js';
|
||||
|
||||
// -- Undelivered backlog ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Deliveries one subscription may hold undelivered.
|
||||
*
|
||||
* A `single` delivery waits until something takes it, so a subscription whose
|
||||
* consumer is gone accumulates. Over the cap the oldest go and one gap marker
|
||||
* takes their place, which is what keeps "at-least-once" honest: what was lost
|
||||
* is visible rather than silent.
|
||||
*/
|
||||
export const EVENTS_PENDING_DELIVERIES_PER_SUBSCRIPTION = 10_000;
|
||||
|
||||
/**
|
||||
* Undelivered deliveries one region may hold across every subscription.
|
||||
*
|
||||
* The per-subscription cap bounds one backlog and nothing in aggregate —
|
||||
* multiply it by the subscriptions that can exist and the region's memory is
|
||||
* the only remaining limit. Over this, the oldest deliveries in the region are
|
||||
* shed first, each shedding subscription gets a gap marker, and an alarm says
|
||||
* it happened.
|
||||
*/
|
||||
export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
|
||||
|
||||
// -- Handler retries -------------------------------------------------
|
||||
//
|
||||
// A handler that answers "not now" — a 5xx, a timeout, a 429 — is retried, and
|
||||
// a handler that answers "no" is not. Retrying on a fixed cadence turns one
|
||||
// broken deploy into a permanent load on whatever is failing, so the wait
|
||||
// doubles per attempt up to a ceiling, and a run of failures stops the
|
||||
// subscription rather than retrying it forever.
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* Failures in a row before a subscription is suspended. Counted per
|
||||
* subscription and reset by the first delivery a handler takes, so an
|
||||
* occasional failure never accumulates into one.
|
||||
*/
|
||||
export const EVENTS_CONSECUTIVE_FAILURES = 5;
|
||||
|
||||
/**
|
||||
* How long a run of failures is remembered. Five failures at the capped wait
|
||||
* span well under this, so a counter nothing has touched for an hour describes
|
||||
* a run that ended.
|
||||
*/
|
||||
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
|
||||
// pair is a free memory hold: removing one widely-subscribed handler would
|
||||
// otherwise turn every dependent into a full backlog nobody pays for. So a
|
||||
// suspension trims to a much smaller cap and stamps an expiry, and the pending
|
||||
// sweeper drops what is left over with a gap marker in its place.
|
||||
|
||||
/** Deliveries a suspended subscription may keep, whatever the reason. */
|
||||
export const EVENTS_SUSPENDED_PENDING_CAP = 100;
|
||||
|
||||
/**
|
||||
* How long a backlog held for a handler that may come back is kept. A bad
|
||||
* deploy is recoverable within a day; past that the events are stale anyway.
|
||||
*/
|
||||
export const EVENTS_SUSPENDED_BACKLOG_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long a backlog held for an account out of credit is kept. The resume
|
||||
* condition is a top-up, which is usually minutes.
|
||||
*/
|
||||
export const EVENTS_NO_CREDIT_BACKLOG_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
// -- Coalescing ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Debounce window per (subscription, subject). A multipart upload or a
|
||||
* recursive delete is one intent that lands as many writes; this is what makes
|
||||
* it arrive as one event.
|
||||
*/
|
||||
export const EVENTS_COALESCE_WINDOW_MS = 250;
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `EventsWorkerDeployer` against stubbed layers — no real Cloudflare, no real
|
||||
* database — pinning the in-flight keying and the per-app deploy throttle
|
||||
* without booting the full test server.
|
||||
*/
|
||||
|
||||
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 { handlerSetHash } from '../../services/events/workerSource.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
|
||||
const SOURCE_HASH = 'a'.repeat(64);
|
||||
const SCRIPT = eventsWorkerScript(
|
||||
handlerSetHash([{ name: 'h', sourceHash: SOURCE_HASH }]),
|
||||
'',
|
||||
);
|
||||
|
||||
interface Owner {
|
||||
ownerUserId: number;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
/** The private layers shape `EventsWorkerDeployer` takes — not exported. */
|
||||
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 redis = new MockRedis.Cluster(['redis://localhost:7001']);
|
||||
const created: string[] = [];
|
||||
const layers = {
|
||||
config: {
|
||||
events: { workerRuntime: true, internalSecret: 'sekret' },
|
||||
// `#namespace()` needs a local-worker shape to resolve without
|
||||
// throwing — this stub never reaches a real Cloudflare or
|
||||
// Miniflare deploy either way.
|
||||
workers: { localServer: true },
|
||||
} as IConfig,
|
||||
clients: { redis },
|
||||
stores: {
|
||||
eventHandler: {
|
||||
allForApp: async (appUid: string) => [
|
||||
{
|
||||
appUid,
|
||||
name: 'h',
|
||||
source: 'async () => {}',
|
||||
sourceHash: SOURCE_HASH,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
app: {
|
||||
getByUid: async (appUid: string) => {
|
||||
const owner = owners[appUid];
|
||||
return owner === null || owner === undefined
|
||||
? null
|
||||
: { owner_user_id: owner.ownerUserId };
|
||||
},
|
||||
},
|
||||
user: {
|
||||
getById: async (id: number) => {
|
||||
const owner = Object.values(owners).find(
|
||||
(o) => o?.ownerUserId === id,
|
||||
);
|
||||
if (!owner) return null;
|
||||
return {
|
||||
id,
|
||||
uuid: `uuid-${id}`,
|
||||
username: `user-${id}`,
|
||||
suspended: owner.suspended ?? false,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
drivers: {
|
||||
workers: {
|
||||
create: async (args: { workerName: string }) => {
|
||||
created.push(args.workerName);
|
||||
return { success: true, errors: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return { layers: layers as unknown as Layers, created, redis };
|
||||
};
|
||||
|
||||
describe('EventsWorkerDeployer.ensure', () => {
|
||||
it('keys in-flight deploys by app and script, not script alone', async () => {
|
||||
// Two apps whose handler sets hash identically, and so deploy as the
|
||||
// same script; only one has a live owner. Both `ensure()` calls run
|
||||
// synchronously back to back — keying the in-flight map by script
|
||||
// alone would collapse the second call into the first's outcome.
|
||||
const { layers } = makeLayers({
|
||||
'app-a': { ownerUserId: 1 },
|
||||
'app-b': { ownerUserId: 2, suspended: true },
|
||||
});
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
deployer.ensure('app-a', SCRIPT),
|
||||
deployer.ensure('app-b', SCRIPT),
|
||||
]);
|
||||
expect(a).toBe('deployed');
|
||||
expect(b).toBe('no-owner');
|
||||
});
|
||||
|
||||
it('refuses once the app has deployed past the hourly cap', async () => {
|
||||
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
|
||||
const deployer = new EventsWorkerDeployer(layers);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
await redis.set(
|
||||
`ev:deploys:{app-c}:${hour}`,
|
||||
String(EVENTS_WORKER_DEPLOYS_PER_HOUR),
|
||||
);
|
||||
|
||||
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* 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 { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { puterClients } from '../../clients/index.js';
|
||||
import type { puterDrivers } from '../../drivers/index.js';
|
||||
import {
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
INTERNAL_DEPLOY_TARGET,
|
||||
} from '../../drivers/workers/WorkerDriver.js';
|
||||
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import {
|
||||
EVENTS_HANDLED_HEADER,
|
||||
EVENTS_INVOKE_PATH,
|
||||
} from '../../clients/events/EventsWorkerInvokerClient.js';
|
||||
import type { puterServices } from '../../services/index.js';
|
||||
import {
|
||||
eventsInvokeKey,
|
||||
eventsWorkerScope,
|
||||
eventsWorkerScript,
|
||||
} from '../../services/events/workerRuntime.js';
|
||||
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
import type { IConfig, LayerInstances } from '../../types.js';
|
||||
import { 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.
|
||||
*/
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed';
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
drivers: LayerInstances<typeof puterDrivers>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ev:deploys:{<appUid>}:<hour>` bucket TTL — comfortably past the hour it
|
||||
* counts.
|
||||
*/
|
||||
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
|
||||
|
||||
interface DeployResult {
|
||||
success?: boolean;
|
||||
errors?: unknown[];
|
||||
}
|
||||
|
||||
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>>();
|
||||
|
||||
constructor(layers: DeployLayers) {
|
||||
this.#layers = layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether handler sets deploy at all. Off means handler CRUD is storage
|
||||
* only.
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return this.#layers.config.events?.workerRuntime === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
|
||||
const key = `${appUid}|${script}`;
|
||||
const existing = this.#inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const started = this.#deploy(appUid, script).finally(() =>
|
||||
this.#inFlight.delete(key),
|
||||
);
|
||||
this.#inFlight.set(key, started);
|
||||
return started;
|
||||
}
|
||||
|
||||
async #deploy(
|
||||
appUid: string,
|
||||
script: string,
|
||||
): Promise<EventsDeployOutcome> {
|
||||
const { stores, drivers } = this.#layers;
|
||||
|
||||
const handlers = await stores.eventHandler.allForApp(appUid);
|
||||
if (handlers.length === 0) return 'no-handlers';
|
||||
|
||||
const generated = generateEventsWorkerSource(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(
|
||||
generated.setHash,
|
||||
eventsWorkerScope(this.#layers.config),
|
||||
) !== script
|
||||
)
|
||||
return 'stale';
|
||||
if (generated.tooLarge) {
|
||||
console.error(
|
||||
`[events] ${appUid}: generated events worker exceeds the size cap`,
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
if (generated.broken.length > 0)
|
||||
console.warn(
|
||||
`[events] ${appUid}: handlers not runnable as published:`,
|
||||
generated.broken.join(', '),
|
||||
);
|
||||
|
||||
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
|
||||
|
||||
const owner = await this.#owner(appUid);
|
||||
if (!owner) return 'no-owner';
|
||||
|
||||
const secret = this.#layers.config.events?.internalSecret;
|
||||
if (!secret) {
|
||||
console.error(
|
||||
'[events] no internalSecret configured: an events worker deployed without an invoke key would refuse every delivery',
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// Deployed as the owner, so the deploy is theirs: their app binding,
|
||||
// their namespace quota, their name in whatever reads the deploy.
|
||||
// Admission is bypassed because this is not a deploy they asked for —
|
||||
// publishing is the gate, and an owner whose email lapsed afterwards
|
||||
// must not silently stop delivering.
|
||||
try {
|
||||
const result = (await runWithContext({ actor: owner }, async () =>
|
||||
drivers.workers.create({
|
||||
appId: appUid,
|
||||
workerName: script,
|
||||
filePath: '',
|
||||
[INTERNAL_ADMISSION_BYPASS]: true,
|
||||
[INTERNAL_DEPLOY_TARGET]: {
|
||||
scriptName: script,
|
||||
namespace: this.#namespace(),
|
||||
runtime: 'events',
|
||||
source: generated.source,
|
||||
omitOwnerToken: true,
|
||||
// What the script compares an invocation against.
|
||||
// Derived from the script name, so a redeploy of
|
||||
// the same set binds the same key.
|
||||
secrets: {
|
||||
events_invoke_key: eventsInvokeKey(secret, script),
|
||||
},
|
||||
// Names are hashes and do not reverse, so tags are
|
||||
// the only way to answer "whose scripts are these?"
|
||||
// when cleaning up superseded sets.
|
||||
tags: [
|
||||
`events-app:${appUid}`,
|
||||
`user:${owner.user.uuid}`,
|
||||
],
|
||||
},
|
||||
}),
|
||||
)) as DeployResult | undefined;
|
||||
// A deploy the platform rejected comes back as a result rather
|
||||
// than a throw, and must not be reported as a success.
|
||||
if (result?.success === false)
|
||||
throw new Error(
|
||||
(result.errors ?? []).map(String).join('; ') ||
|
||||
'deploy rejected',
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[events] ${appUid}: events worker deploy failed`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return 'failed';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** The app owner, as an actor the deploy can run as. */
|
||||
async #owner(
|
||||
appUid: string,
|
||||
): 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;
|
||||
|
||||
const user = await stores.user.getById(ownerUserId);
|
||||
if (!user?.id || !user.uuid || !user.username || user.suspended)
|
||||
return null;
|
||||
return { user } as Actor & {
|
||||
user: { id: number; uuid: string; username: string };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where events workers are deployed. Deliberately has no default: without a
|
||||
* value there is nowhere to put them that is not somewhere else's, and the
|
||||
* default namespace is the one the public dispatcher serves.
|
||||
*/
|
||||
#namespace(): string | undefined {
|
||||
const namespace = this.#layers.config.events?.workerNamespace;
|
||||
if (namespace) {
|
||||
// The public dispatcher resolves any script in its namespace off
|
||||
// the request hostname, with no further check, so sharing that
|
||||
// namespace would put every events worker on the public worker
|
||||
// domain — the one thing this deploy target exists to prevent.
|
||||
if (namespace === this.#layers.config.workers?.namespace)
|
||||
throw new HttpError(
|
||||
503,
|
||||
'events.workerNamespace must not be the public worker namespace',
|
||||
{ legacyCode: 'response_timeout' },
|
||||
);
|
||||
return namespace;
|
||||
}
|
||||
// A local worker runtime has no namespaces to keep apart, and no
|
||||
// dispatcher that could serve one either way.
|
||||
if (
|
||||
this.#layers.config.workers?.localServer &&
|
||||
!this.#layers.config.workers?.ACCOUNTID
|
||||
)
|
||||
return undefined;
|
||||
throw new HttpError(503, 'Events workers are not configured', {
|
||||
legacyCode: 'response_timeout',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local-development transport: no dispatcher, no namespace, no HTTP hop —
|
||||
* the invocation goes straight into the local worker runtime, and a script that
|
||||
* is not resident is deployed on the spot the way the dispatcher's miss path
|
||||
* would have asked for.
|
||||
*/
|
||||
export class LocalEventsInvokeTransport {
|
||||
readonly #services: Pick<DeployLayers['services'], 'localworkerservice'>;
|
||||
readonly #deployer: EventsWorkerDeployer;
|
||||
|
||||
constructor(
|
||||
services: Pick<DeployLayers['services'], 'localworkerservice'>,
|
||||
deployer: EventsWorkerDeployer,
|
||||
) {
|
||||
this.#services = services;
|
||||
this.#deployer = deployer;
|
||||
}
|
||||
|
||||
async send(call: EventsInvokeCall): Promise<{
|
||||
status: number | null;
|
||||
handled?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const request = (): Request =>
|
||||
new Request(
|
||||
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-events-key': call.key,
|
||||
},
|
||||
body: call.body,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const resident = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (resident !== null) return resident;
|
||||
|
||||
const outcome = await this.#deployer.ensure(
|
||||
call.appUid,
|
||||
call.script,
|
||||
);
|
||||
if (outcome !== 'deployed')
|
||||
return { status: null, error: `deploy: ${outcome}` };
|
||||
|
||||
const retried = await this.#dispatch(request(), call.timeoutMs);
|
||||
if (retried === null)
|
||||
return { status: null, error: 'script not resident' };
|
||||
return retried;
|
||||
} catch (err) {
|
||||
return {
|
||||
status: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` when the script is not deployed here. The timeout is ours to
|
||||
* apply: an in-process dispatch has no socket to abort.
|
||||
*/
|
||||
async #dispatch(
|
||||
request: Request,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; handled: boolean } | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||
timer.unref();
|
||||
});
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
this.#services.localworkerservice.dispatchEventsWorker(request),
|
||||
timeout,
|
||||
]);
|
||||
if (response === 'timeout') throw new Error('invocation timed out');
|
||||
if (response === null) return null;
|
||||
return {
|
||||
status: response.status,
|
||||
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,4 +226,131 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
const body = (await response.json()) as Array<{ name: string }>;
|
||||
expect(body.map((e) => e.name)).toContain('Documents');
|
||||
});
|
||||
|
||||
describe('the share flag', () => {
|
||||
const post = async (path: string, token: string, body: unknown) => {
|
||||
const response = await fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
return response.json() as Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const mkdir = (path: string, token: string) =>
|
||||
post('/fs/mkdir', token, { path });
|
||||
|
||||
const readdir = async (path: string, token: string) => {
|
||||
const response = await fetch(readdirUrl({ path, auth_token: token }));
|
||||
expect(response.status).toBe(200);
|
||||
const entries = (await response.json()) as Array<{
|
||||
name: string;
|
||||
isShared: boolean | null;
|
||||
}>;
|
||||
return new Map(entries.map((e) => [e.name, e.isShared]));
|
||||
};
|
||||
|
||||
const base = () =>
|
||||
`/${env.users.user.username}/Documents/flag-${crypto.randomUUID().slice(0, 8)}`;
|
||||
|
||||
it('marks a shared child in a listing, and clears it on revoke', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
await mkdir(`${parent}/private`, owner.token);
|
||||
|
||||
// A write response says nothing about sharing.
|
||||
expect(shared).not.toHaveProperty('isShared');
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
expect(await readdir(parent, owner.token)).toEqual(
|
||||
new Map([
|
||||
['shared', true],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
|
||||
await post('/share/revoke', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
});
|
||||
|
||||
expect(await readdir(parent, owner.token)).toEqual(
|
||||
new Map([
|
||||
['shared', false],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('names the recipients in stat only when asked', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const plain = await post('/fs/stat', owner.token, {
|
||||
uid: shared.uuid,
|
||||
});
|
||||
expect(plain.isShared).toBe(true);
|
||||
expect(plain).not.toHaveProperty('shares');
|
||||
|
||||
|
||||
const withShares = await post('/fs/stat', owner.token, {
|
||||
uid: shared.uuid,
|
||||
return_shares: true,
|
||||
});
|
||||
const shares = withShares.shares as Array<Record<string, unknown>>;
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0]).toMatchObject({
|
||||
holder: recipient.username,
|
||||
issuer: owner.username,
|
||||
mode: 'read',
|
||||
inherited_from: null,
|
||||
});
|
||||
for (const key of ['holder_user_id', 'issuer_user_id', 'fsentry_id']) {
|
||||
expect(shares[0]).not.toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('tells a recipient nothing about who else can reach the item', async () => {
|
||||
const owner = env.users.user;
|
||||
const recipient = env.users.other;
|
||||
const parent = base();
|
||||
await mkdir(parent, owner.token);
|
||||
const shared = await mkdir(`${parent}/shared`, owner.token);
|
||||
|
||||
await post('/share', owner.token, {
|
||||
recipients: [recipient.username],
|
||||
items: [{ uid: shared.uuid }],
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const stat = await post('/fs/stat', recipient.token, {
|
||||
uid: shared.uuid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.isShared).toBeNull();
|
||||
// Asked for, so the key is there — but it names nobody.
|
||||
expect(stat.shares).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,9 @@ import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import {
|
||||
assertNormalized,
|
||||
expandTildePath,
|
||||
isOwnersTrash,
|
||||
normalizeAbsolutePath,
|
||||
} from '../../services/fs/resolveNode.js';
|
||||
import {
|
||||
maskEntryPath,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
runWithConcurrencyLimitSettled,
|
||||
} from '../../util/concurrency.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
@@ -151,7 +153,10 @@ export class FSController extends PuterController {
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
const requestBody = this.#withGuiMetadata(req.body, req.body);
|
||||
const requestBody = this.#withGuiMetadata(
|
||||
this.#requireObjectBody(req.body),
|
||||
req.body,
|
||||
);
|
||||
requestBody.fileMetadata = await this.#normalizeFileMetadataPath(
|
||||
req,
|
||||
requestBody.fileMetadata,
|
||||
@@ -219,7 +224,7 @@ export class FSController extends PuterController {
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
const normalizedRequestBody = this.#withGuiMetadata(
|
||||
requestBody,
|
||||
this.#requireObjectBody(requestBody),
|
||||
req.body,
|
||||
);
|
||||
normalizedRequestBody.fileMetadata =
|
||||
@@ -318,7 +323,10 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientCompleteWriteResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requestBody = this.#withGuiMetadata(req.body, req.body);
|
||||
const requestBody = this.#withGuiMetadata(
|
||||
this.#requireObjectBody(req.body),
|
||||
req.body,
|
||||
);
|
||||
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
|
||||
|
||||
const response = await this.services.fs.completeUrlWrite(
|
||||
@@ -356,7 +364,10 @@ export class FSController extends PuterController {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const requests = Array.isArray(req.body)
|
||||
? req.body.map((requestBody) => {
|
||||
return this.#withGuiMetadata(requestBody, req.body);
|
||||
return this.#withGuiMetadata(
|
||||
this.#requireObjectBody(requestBody),
|
||||
req.body,
|
||||
);
|
||||
})
|
||||
: [];
|
||||
for (const requestBody of requests) {
|
||||
@@ -442,7 +453,10 @@ export class FSController extends PuterController {
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
const requestBody = this.#withGuiMetadata(req.body, req.body);
|
||||
const requestBody = this.#withGuiMetadata(
|
||||
this.#requireObjectBody(req.body),
|
||||
req.body,
|
||||
);
|
||||
requestBody.fileMetadata = await this.#normalizeFileMetadataPath(
|
||||
req,
|
||||
requestBody.fileMetadata,
|
||||
@@ -840,7 +854,7 @@ export class FSController extends PuterController {
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
const normalizedRequestBody = this.#withGuiMetadata(
|
||||
requestBody,
|
||||
this.#requireObjectBody(requestBody),
|
||||
req.body,
|
||||
);
|
||||
normalizedRequestBody.fileMetadata =
|
||||
@@ -959,9 +973,14 @@ export class FSController extends PuterController {
|
||||
|
||||
// -- Read-side routes ------------------------------------------------
|
||||
|
||||
// The read-side routes admit scoped access tokens: `#assertAccess` runs the
|
||||
// ACL against the token's grant intersected with its issuer's, so a token
|
||||
// reaches exactly what it was minted for. A background events handler
|
||||
// acts through one — its `user` is scoped to the subscription's grant.
|
||||
@Post('/stat', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_STAT_LIMIT,
|
||||
})
|
||||
async statEntry(req: Request, res: Response) {
|
||||
@@ -972,17 +991,28 @@ export class FSController extends PuterController {
|
||||
await this.#assertAccess(actor, entry.path, 'see');
|
||||
|
||||
const wantsSize = this.#toBoolean(body.return_size);
|
||||
const subtreeSize =
|
||||
entry.isDir && wantsSize
|
||||
? await this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
: undefined;
|
||||
|
||||
entry.suggestedApps =
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
const [subtreeSize, suggestedApps, shareFlags, shares] =
|
||||
await Promise.all([
|
||||
entry.isDir && wantsSize
|
||||
? this.services.fs.getSubtreeSize(userId, entry.path)
|
||||
: undefined,
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
this.#toBoolean(body.return_shares)
|
||||
? listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
actor,
|
||||
entry.uuid,
|
||||
)
|
||||
: undefined,
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
res.json({
|
||||
...this.#toClientEntry(entry),
|
||||
...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null),
|
||||
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
|
||||
...(shares !== undefined ? { shares } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -994,7 +1024,7 @@ export class FSController extends PuterController {
|
||||
* (with public folders enabled) any authenticated user. The legacy read
|
||||
* path already curates its output; this does the same for the v2 routes.
|
||||
*/
|
||||
#toClientEntry(entry: FSEntry): ClientFSEntry {
|
||||
#toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry {
|
||||
// Allowlist, not a denylist: a denylist silently ships every column
|
||||
// added to `fsentries` later. Omits the numeric primary keys (`id`,
|
||||
// `parentId`, `associatedAppId`), the storage columns, the owning
|
||||
@@ -1026,6 +1056,8 @@ export class FSController extends PuterController {
|
||||
workers: entry.workers ?? [],
|
||||
hasWebsite: entry.hasWebsite ?? subdomains.length > 0,
|
||||
suggestedApps: entry.suggestedApps ?? [],
|
||||
// Read paths pass a value; write responses leave it off entirely.
|
||||
...(isShared === undefined ? {} : { isShared }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1080,6 +1112,7 @@ export class FSController extends PuterController {
|
||||
@Get('/readdir', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_READDIR_LIMIT,
|
||||
})
|
||||
async readdirEntriesViaGet(req: Request, res: Response) {
|
||||
@@ -1089,6 +1122,7 @@ export class FSController extends PuterController {
|
||||
@Post('/readdir', {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_READDIR_LIMIT,
|
||||
})
|
||||
async readdirEntries(req: Request, res: Response) {
|
||||
@@ -1134,7 +1168,7 @@ export class FSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
const rootItems = await this.#toReaddirEntries(rootChildren);
|
||||
const rootItems = await this.#toReaddirEntries(actor, rootChildren);
|
||||
if (paginated) {
|
||||
res.json({
|
||||
items: rootItems,
|
||||
@@ -1207,7 +1241,7 @@ export class FSController extends PuterController {
|
||||
)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
items: await this.#toReaddirEntries(actor, page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
@@ -1227,7 +1261,7 @@ export class FSController extends PuterController {
|
||||
? await this.services.fs.countDirectory(parent.uuid)
|
||||
: undefined;
|
||||
res.json({
|
||||
items: await this.#toReaddirEntries(page.entries),
|
||||
items: await this.#toReaddirEntries(actor, page.entries),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(total !== undefined ? { total } : {}),
|
||||
});
|
||||
@@ -1241,7 +1275,7 @@ export class FSController extends PuterController {
|
||||
sortOrder,
|
||||
});
|
||||
await this.#attachSuggestedApps(children);
|
||||
res.json(await this.#toReaddirEntries(children));
|
||||
res.json(await this.#toReaddirEntries(actor, children));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1249,14 +1283,20 @@ export class FSController extends PuterController {
|
||||
* the three fields the SDK cannot reconstruct on its own so it can rebuild
|
||||
* the v1 shape: `type` (MIME), a signed `thumbnail`, and `associatedApp`.
|
||||
*/
|
||||
async #toReaddirEntries(entries: FSEntry[]): Promise<ClientReaddirEntry[]> {
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
entries,
|
||||
);
|
||||
async #toReaddirEntries(
|
||||
actor: Actor,
|
||||
entries: FSEntry[],
|
||||
): Promise<ClientReaddirEntry[]> {
|
||||
const [appsById, shareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, entries),
|
||||
this.services.share.shareFlags(actor, entries),
|
||||
]);
|
||||
return Promise.all(
|
||||
entries.map(async (entry) => ({
|
||||
...this.#toClientEntry(entry),
|
||||
...this.#toClientEntry(
|
||||
entry,
|
||||
shareFlags.get(entry.uuid) ?? null,
|
||||
),
|
||||
// Fields the client cannot derive on its own.
|
||||
type: fsEntryMimeType(entry),
|
||||
thumbnail: await signEntryThumbnail(
|
||||
@@ -1320,6 +1360,7 @@ export class FSController extends PuterController {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
requireCredits: true,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_READ_LIMIT,
|
||||
concurrent: FS_READ_CONCURRENT,
|
||||
})
|
||||
@@ -2220,33 +2261,7 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
#normalizePath(path: string, username?: string): string {
|
||||
const trimmedPath = path.trim();
|
||||
if (trimmedPath.length === 0) {
|
||||
throw new HttpError(400, 'Path cannot be empty', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
let pathToNormalize = trimmedPath;
|
||||
if (pathToNormalize === '~' || pathToNormalize.startsWith('~/')) {
|
||||
if (!username) {
|
||||
throw new HttpError(400, 'Unable to resolve home path', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
pathToNormalize = `/${username}${pathToNormalize.slice(1)}`;
|
||||
}
|
||||
|
||||
assertNormalized(pathToNormalize);
|
||||
let normalizedPath = pathToNormalize;
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = `/${normalizedPath}`;
|
||||
}
|
||||
if (normalizedPath.length > 1 && normalizedPath.endsWith('/')) {
|
||||
normalizedPath = normalizedPath.slice(0, -1);
|
||||
}
|
||||
return normalizedPath;
|
||||
return normalizeAbsolutePath(expandTildePath(path, username));
|
||||
}
|
||||
|
||||
#expandFileMetadataPath(
|
||||
@@ -2342,6 +2357,20 @@ export class FSController extends PuterController {
|
||||
return guiMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* The write handlers build on `req.body` being an object. A request whose
|
||||
* body never parsed leaves it undefined, and the first field read after
|
||||
* that is a 500 where the request deserves a 400.
|
||||
*/
|
||||
#requireObjectBody<T>(body: T | undefined): T {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw new HttpError(400, 'A request body is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
#withGuiMetadata<T extends { guiMetadata?: WriteGuiMetadata }>(
|
||||
value: T,
|
||||
fallbackSource: unknown,
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* Puter is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option) any
|
||||
* later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
* along with this program. If not, see
|
||||
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -542,6 +543,74 @@ describe('FSController.write', () => {
|
||||
// false makes the ceiling free disk space), so this group runs its own
|
||||
// server with the limit switched on.
|
||||
|
||||
describe('write handlers with no parsable body', () => {
|
||||
// A request whose body never parsed leaves `req.body` undefined, and the
|
||||
// handlers read fileMetadata straight off it.
|
||||
it.each([
|
||||
[
|
||||
'write',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.write(r as never, res as never),
|
||||
],
|
||||
[
|
||||
'startWrite',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.startWrite(r as never, res as never),
|
||||
],
|
||||
[
|
||||
'completeWrite',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.completeWrite(r as never, res as never),
|
||||
],
|
||||
])('%s answers 400', async (_label, call) => {
|
||||
const { actor } = await makeUser();
|
||||
const req = makeReq({ actor });
|
||||
(req as { body?: unknown }).body = undefined;
|
||||
const { res } = makeRes();
|
||||
|
||||
await expect(
|
||||
withActor(actor, () => call(controller, req, res)),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch write handlers with a malformed element', () => {
|
||||
// The body is an array, so the array check passes; the elements are what
|
||||
// the handlers then read fileMetadata / thumbnailData off.
|
||||
it.each([
|
||||
[
|
||||
'startBatchWrites',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.startBatchWrites(r as never, res as never),
|
||||
],
|
||||
[
|
||||
'batchWrites',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.batchWrites(r as never, res as never),
|
||||
],
|
||||
[
|
||||
'completeBatchWrites',
|
||||
(c: typeof controller, r: Request, res: Response) =>
|
||||
c.completeBatchWrites(r as never, res as never),
|
||||
],
|
||||
])('%s answers 400 for a null element', async (_label, call) => {
|
||||
const { actor } = await makeUser();
|
||||
const req = makeReq({ actor });
|
||||
(req as { body?: unknown }).body = [null];
|
||||
const { res } = makeRes();
|
||||
|
||||
await expect(
|
||||
withActor(actor, () => call(controller, req, res)),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FSController.write storage allowance', () => {
|
||||
let limitedServer: PuterServer;
|
||||
let limitedController: FSController;
|
||||
|
||||
@@ -331,6 +331,55 @@ describe('LegacyFSController inline routes', () => {
|
||||
expect(entry).not.toHaveProperty('owner_user_id');
|
||||
});
|
||||
|
||||
it('pairs a recent app icon with a direct subdomain URL at the asked size', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const app = (await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; name: string }>
|
||||
)(
|
||||
{
|
||||
name: `recent-${uuidv4()}`,
|
||||
title: 'Recent App',
|
||||
index_url: 'https://recent.example.test/',
|
||||
// An http(s) icon column is what the resize pipeline leaves
|
||||
// behind, so the sized PNG is on the icons subdomain.
|
||||
icon: 'https://api.puter.com/app-icon/x',
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
|
||||
[app.uid, userId, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
|
||||
const runWithIconSize = async (query: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await routeHandler('get', '/get-launch-apps')(
|
||||
makeReq({ actor, query }),
|
||||
res,
|
||||
(() => {}) as never,
|
||||
);
|
||||
const body = captured.body as {
|
||||
recent: Array<Record<string, unknown>>;
|
||||
};
|
||||
return body.recent.find((item) => item.uuid === app.uid)!;
|
||||
};
|
||||
|
||||
const { protocol, static_hosting_domain } = controller.config;
|
||||
const base = `${protocol}://puter-app-icons.${static_hosting_domain}`;
|
||||
|
||||
expect((await runWithIconSize({ icon_size: '64' })).iconCdnUrl).toBe(
|
||||
`${base}/${app.uid}-64.png`,
|
||||
);
|
||||
// An unusable size falls back to the default rather than pointing at a
|
||||
// file the pipeline never wrote.
|
||||
expect((await runWithIconSize({ icon_size: '65' })).iconCdnUrl).toBe(
|
||||
`${base}/${app.uid}-256.png`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a zero cache timestamp when unauthenticated', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await routeHandler('get', '/cache/last-change-timestamp')(
|
||||
|
||||
@@ -373,6 +373,47 @@ describe('LegacyFSController.stat', () => {
|
||||
expect(body.size).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the share flag, and the recipients when asked', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Documents/shared-folder`;
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
|
||||
);
|
||||
|
||||
const before = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(makeReq({ body: { path }, actor }), before.res),
|
||||
);
|
||||
expect(before.captured.body).toMatchObject({ is_shared: false });
|
||||
|
||||
await withActor(actor, () =>
|
||||
server.services.share.share(actor, {
|
||||
path,
|
||||
recipient: { username: recipient.actor.user!.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
|
||||
const after = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.stat(
|
||||
makeReq({ body: { path, return_shares: true }, actor }),
|
||||
after.res,
|
||||
),
|
||||
);
|
||||
const body = after.captured.body as Record<string, unknown>;
|
||||
expect(body.is_shared).toBe(true);
|
||||
expect(body.shares).toMatchObject([
|
||||
{
|
||||
holder: recipient.actor.user!.username,
|
||||
issuer: username,
|
||||
mode: 'read',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws 401 when the request has no actor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
@@ -650,6 +691,44 @@ describe('LegacyFSController.readdir', () => {
|
||||
expect(names).toContain('beta');
|
||||
});
|
||||
|
||||
it('flags a shared child in the listing', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const parent = `/${username}/Documents/flagged`;
|
||||
for (const name of ['', '/shared', '/private']) {
|
||||
await withActor(actor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({ body: { path: `${parent}${name}` }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await withActor(actor, () =>
|
||||
server.services.share.share(actor, {
|
||||
path: `${parent}/shared`,
|
||||
recipient: { username: recipient.actor.user!.username! },
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdir(makeReq({ body: { path: parent }, actor }), res),
|
||||
);
|
||||
const entries = captured.body as Array<{
|
||||
name: string;
|
||||
is_shared: boolean | null;
|
||||
}>;
|
||||
expect(new Map(entries.map((e) => [e.name, e.is_shared]))).toEqual(
|
||||
new Map([
|
||||
['shared', true],
|
||||
['private', false],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the root listing when path = "/"', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -41,12 +41,15 @@ import {
|
||||
NON_OWNER_SIGNATURE_TTL_SECONDS,
|
||||
verifySignature,
|
||||
} from '../../util/fileSigning.js';
|
||||
import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { maskEntryPath } from '../../services/fs/sharePathMask.js';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
} from '../../util/hostedAppBacking.js';
|
||||
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
|
||||
import { listClientShares } from '../share/clientShare.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
FS_BATCH_CONCURRENT,
|
||||
@@ -141,14 +144,16 @@ export class LegacyFSController extends PuterController {
|
||||
// draw from the same per-user budget as their v2 counterparts rather
|
||||
// than handing a caller a second allowance for the same operation.
|
||||
const mutate = { ...apiOptions, rateLimit: FS_MUTATE_LIMIT };
|
||||
router.post(
|
||||
'/stat',
|
||||
{ ...apiOptions, rateLimit: FS_STAT_LIMIT },
|
||||
this.stat,
|
||||
);
|
||||
// Read-side routes admit scoped access tokens: the ACL check each
|
||||
// handler runs already intersects a token's grant with its issuer's,
|
||||
// so the token reaches exactly what it was minted for. That is what a
|
||||
// background events handler acts through — its `user` is a token
|
||||
// scoped to the subscription's grant.
|
||||
const reads = { ...apiOptions, allowAccessToken: true } as RouteOptions;
|
||||
router.post('/stat', { ...reads, rateLimit: FS_STAT_LIMIT }, this.stat);
|
||||
router.post(
|
||||
'/readdir',
|
||||
{ ...apiOptions, rateLimit: FS_READDIR_LIMIT },
|
||||
{ ...reads, rateLimit: FS_READDIR_LIMIT },
|
||||
this.readdir,
|
||||
);
|
||||
router.post('/mkdir', mutate, this.mkdir);
|
||||
@@ -170,6 +175,7 @@ export class LegacyFSController extends PuterController {
|
||||
'/read',
|
||||
{
|
||||
...spends,
|
||||
allowAccessToken: true,
|
||||
rateLimit: FS_READ_LIMIT,
|
||||
concurrent: FS_READ_CONCURRENT,
|
||||
},
|
||||
@@ -291,6 +297,14 @@ export class LegacyFSController extends PuterController {
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
|
||||
// The direct icon URL names a size; honour the one the
|
||||
// caller asked for so the client isn't handed a 256px PNG for
|
||||
// a 64px slot. `icon` keeps the raw column either way.
|
||||
const requestedIconSize = Number(req.query.icon_size);
|
||||
const iconSize = APP_ICON_SIZES.includes(requestedIconSize)
|
||||
? requestedIconSize
|
||||
: undefined;
|
||||
|
||||
let recent: unknown[] = [];
|
||||
const userId = req.actor?.user?.id;
|
||||
if (userId) {
|
||||
@@ -344,6 +358,13 @@ export class LegacyFSController extends PuterController {
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon ?? null,
|
||||
// Direct subdomain URL for the client to try
|
||||
// before `icon`.
|
||||
iconCdnUrl: getAppIconCdnUrl(
|
||||
app,
|
||||
this.config,
|
||||
iconSize,
|
||||
),
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: backingGone ? null : app.index_url,
|
||||
@@ -437,12 +458,12 @@ export class LegacyFSController extends PuterController {
|
||||
'see',
|
||||
);
|
||||
|
||||
entry.suggestedApps =
|
||||
await this.services.suggestedApps.getSuggestedApps(entry);
|
||||
|
||||
const appsById = await loadLegacyAssociatedApps(this.stores.app, [
|
||||
entry,
|
||||
const [suggestedApps, appsById, shareFlags] = await Promise.all([
|
||||
this.services.suggestedApps.getSuggestedApps(entry),
|
||||
loadLegacyAssociatedApps(this.stores.app, [entry]),
|
||||
this.services.share.shareFlags(actor, [entry]),
|
||||
]);
|
||||
entry.suggestedApps = suggestedApps;
|
||||
|
||||
const shaped = await toLegacyEntry(this.clients.event, entry, {
|
||||
fsEntryStore: this.stores.fsEntry,
|
||||
@@ -452,6 +473,7 @@ export class LegacyFSController extends PuterController {
|
||||
) => Promise<Record<string, unknown> | null>;
|
||||
},
|
||||
appsById,
|
||||
isShared: shareFlags.get(entry.uuid) ?? null,
|
||||
});
|
||||
|
||||
// Optional hydrations:
|
||||
@@ -461,13 +483,17 @@ export class LegacyFSController extends PuterController {
|
||||
entry.path,
|
||||
);
|
||||
}
|
||||
// Legacy clients sometimes ask for `return_versions`, `return_shares`.
|
||||
// We don't have parity for these yet — return empty arrays to avoid
|
||||
// breaking `response.x.forEach(...)` patterns. `return_owner` is a
|
||||
// no-op flag here: the `owner` field is already populated by
|
||||
// `toLegacyEntry` as `{ username }`.
|
||||
// `return_versions` has no parity yet; the empty array keeps `forEach`
|
||||
// callers working. `return_owner` is a no-op — `owner` is always set.
|
||||
if (getBoolean(body, 'return_versions')) shaped.versions = [];
|
||||
if (getBoolean(body, 'return_shares')) shaped.shares = [];
|
||||
if (getBoolean(body, 'return_shares')) {
|
||||
shaped.shares = await listClientShares(
|
||||
this.services.share,
|
||||
this.clients.event,
|
||||
actor,
|
||||
entry.uuid,
|
||||
);
|
||||
}
|
||||
|
||||
res.json(shaped);
|
||||
};
|
||||
@@ -500,14 +526,15 @@ export class LegacyFSController extends PuterController {
|
||||
child.suggestedApps = rootSuggestions[index] ?? [];
|
||||
}
|
||||
}
|
||||
const rootAppsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
rootChildren,
|
||||
);
|
||||
const [rootAppsById, rootShareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, rootChildren),
|
||||
this.services.share.shareFlags(actor, rootChildren),
|
||||
]);
|
||||
const shaped = await Promise.all(
|
||||
rootChildren.map((c) =>
|
||||
toLegacyEntry(this.clients.event, c, {
|
||||
appsById: rootAppsById,
|
||||
isShared: rootShareFlags.get(c.uuid) ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -581,14 +608,17 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
const appsById = await loadLegacyAssociatedApps(
|
||||
this.stores.app,
|
||||
children,
|
||||
);
|
||||
const [appsById, shareFlags] = await Promise.all([
|
||||
loadLegacyAssociatedApps(this.stores.app, children),
|
||||
this.services.share.shareFlags(actor, children),
|
||||
]);
|
||||
|
||||
const shaped = await Promise.all(
|
||||
children.map((c) =>
|
||||
toLegacyEntry(this.clients.event, c, { appsById }),
|
||||
toLegacyEntry(this.clients.event, c, {
|
||||
appsById,
|
||||
isShared: shareFlags.get(c.uuid) ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -628,10 +658,7 @@ export class LegacyFSController extends PuterController {
|
||||
typeof body.parent === 'string' &&
|
||||
(body.parent.startsWith('/') || body.parent.startsWith('~'))
|
||||
) {
|
||||
parentPath = this.#expandTilde(
|
||||
body.parent,
|
||||
actor.user?.username,
|
||||
);
|
||||
parentPath = expandTildePath(body.parent, actor.user?.username);
|
||||
} else {
|
||||
const parent = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
@@ -832,7 +859,9 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
Record<string, unknown> | null | undefined,
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -1279,7 +1308,8 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1900,7 +1930,10 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -2192,7 +2225,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
const parentPath = getString(record, 'path') ?? '';
|
||||
const expandedParent = this.#expandTilde(
|
||||
const expandedParent = expandTildePath(
|
||||
parentPath,
|
||||
username,
|
||||
);
|
||||
@@ -2257,7 +2290,7 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const expandedParent = this.#expandTilde(
|
||||
const expandedParent = expandTildePath(
|
||||
parentPath,
|
||||
username,
|
||||
);
|
||||
@@ -2310,7 +2343,7 @@ export class LegacyFSController extends PuterController {
|
||||
this.stores.fsEntry,
|
||||
{ uid: shortcutToUid },
|
||||
);
|
||||
const expandedParent = this.#expandTilde(
|
||||
const expandedParent = expandTildePath(
|
||||
parentPath,
|
||||
username,
|
||||
);
|
||||
@@ -2544,16 +2577,6 @@ export class LegacyFSController extends PuterController {
|
||||
return [];
|
||||
}
|
||||
|
||||
#expandTilde(path: string, username: string | undefined): string {
|
||||
if (!path) return path;
|
||||
if (path !== '~' && !path.startsWith('~/')) return path;
|
||||
if (!username)
|
||||
throw new HttpError(400, 'Unable to resolve home path', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
return `/${username}${path.slice(1)}`;
|
||||
}
|
||||
|
||||
#serializeBatchError(err: unknown): Record<string, unknown> {
|
||||
if (err instanceof HttpError) {
|
||||
const payload: Record<string, unknown> = {
|
||||
|
||||
@@ -449,6 +449,8 @@ export async function toLegacyEntry(
|
||||
getById: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
};
|
||||
appsById?: Map<number, Record<string, unknown>>;
|
||||
/** Omitted from the response when undefined. */
|
||||
isShared?: boolean | null;
|
||||
} = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Someone else's entry is published under its masked path; the owner's
|
||||
@@ -496,6 +498,7 @@ export async function toLegacyEntry(
|
||||
? (opts.appsById.get(entry.associatedAppId) ?? null)
|
||||
: null,
|
||||
appdata_app,
|
||||
...(opts.isShared === undefined ? {} : { is_shared: opts.isShared }),
|
||||
};
|
||||
|
||||
// `is_empty` — only meaningful for directories. Single-row probe so we
|
||||
|
||||
@@ -243,3 +243,29 @@ export const DAV_CONCURRENT: NonNullable<RouteOptions['concurrent']> = {
|
||||
limit: 10,
|
||||
key: 'fingerprint',
|
||||
};
|
||||
|
||||
/**
|
||||
* Failed HTTP Basic verifications on the DAV host.
|
||||
*
|
||||
* `DAV_LIMIT` counts requests, and a working DAV client resends its credentials
|
||||
* on every one of them, so it cannot double as a credential ceiling — these
|
||||
* count only verifications that failed, which a working client never produces.
|
||||
* The gate reads them before the bcrypt compare, so an exhausted bucket costs
|
||||
* nothing to serve.
|
||||
*
|
||||
* Two axes, sized like `/login`'s, and for the same reasons: per-account so one
|
||||
* account can't be ground down from many networks, plus a coarser per-IP
|
||||
* backstop, because the network fingerprint `DAV_LIMIT` buckets on rotates with
|
||||
* client-controlled headers.
|
||||
*/
|
||||
export const DAV_AUTH_FAILURE_LIMIT: RouteRateLimit = {
|
||||
scope: 'dav-auth',
|
||||
limit: 10,
|
||||
window: 15 * 60_000,
|
||||
};
|
||||
export const DAV_AUTH_FAILURE_IP_LIMIT: RouteRateLimit = {
|
||||
scope: 'dav-auth-ip',
|
||||
limit: 50,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
};
|
||||
@@ -177,6 +177,7 @@ export interface ClientFSEntry {
|
||||
workers: FSEntrySubdomain[];
|
||||
hasWebsite: boolean;
|
||||
suggestedApps: unknown[];
|
||||
isShared?: boolean | null;
|
||||
|
||||
id?: never;
|
||||
userId?: never;
|
||||
|
||||
@@ -278,6 +278,47 @@ describe('HomepageController GET /app/:name', () => {
|
||||
expect(html).toContain('Cool App');
|
||||
});
|
||||
|
||||
it('caps description, og:description and twitter:description at 150 chars', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `long-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const longDescription =
|
||||
'This drawing app has brushes, layers, filters and more. '.repeat(
|
||||
8,
|
||||
);
|
||||
await server.stores.app.create(
|
||||
{
|
||||
name,
|
||||
title: 'Wordy App',
|
||||
description: longDescription,
|
||||
index_url: `https://example.com/${name}/`,
|
||||
},
|
||||
{ ownerUserId: userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/app/:name',
|
||||
makeReq({ params: { name }, path: `/app/${name}` }),
|
||||
res,
|
||||
);
|
||||
|
||||
const html = String(captured.body);
|
||||
for (const tag of [
|
||||
'name="description"',
|
||||
'property="og:description"',
|
||||
'name="twitter:description"',
|
||||
]) {
|
||||
const match = new RegExp(`${tag} content="([^"]*)"`).exec(html);
|
||||
expect(match, `no ${tag} tag`).toBeTruthy();
|
||||
const content = match![1];
|
||||
expect(content.length).toBeLessThanOrEqual(150);
|
||||
expect(content.endsWith('…')).toBe(true);
|
||||
// Cut at a word boundary, not mid-word.
|
||||
expect(longDescription).toContain(content.slice(0, -1));
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leak a private app index_url or owner id to an anonymous visitor', async () => {
|
||||
const { userId } = await makeUser();
|
||||
const name = `priv-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
Loaded 100 of 821 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user