mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908ec23eda | ||
|
|
d5ae5a0049 | ||
|
|
419d0aaa89 | ||
|
|
c3a0c28705 | ||
|
|
7ceb2090b7 | ||
|
|
ea63c3093e | ||
|
|
e8236a100c | ||
|
|
20b3b88e39 | ||
|
|
cbae39a08f | ||
|
|
afad3e37bf | ||
|
|
a6b2161b2c | ||
|
|
9e864e9574 | ||
|
|
79d4201f12 | ||
|
|
0c2d7dfa34 | ||
|
|
2a6d2fea32 | ||
|
|
1dec5e90c4 | ||
|
|
566298d89c | ||
|
|
6bb5eacf61 | ||
|
|
390a83c860 | ||
|
|
8f23bf7b75 | ||
|
|
aa85dddd38 | ||
|
|
8284f35b93 | ||
|
|
ccec3ce39b | ||
|
|
60686643b3 | ||
|
|
61bfcbdc83 | ||
|
|
c8985b621d | ||
|
|
42299a64ed | ||
|
|
344db5cb0b | ||
|
|
66824f607d | ||
|
|
66dedf3082 | ||
|
|
95da802b13 | ||
|
|
0491d5fea7 | ||
|
|
d202be10a9 | ||
|
|
2c17476c07 | ||
|
|
06d94534e8 | ||
|
|
aea828ff12 | ||
|
|
c24f0fe08b | ||
|
|
085ac116c7 | ||
|
|
5538a5d24b | ||
|
|
14c14fb5c5 | ||
|
|
41fc8d890b | ||
|
|
45bdc46df0 | ||
|
|
82ca390bf4 | ||
|
|
edfd67a83c | ||
|
|
ff16ec8900 | ||
|
|
c7edfc0c74 | ||
|
|
09f30d693c | ||
|
|
a9ec15d121 | ||
|
|
eb53c842dd | ||
|
|
1be5ce45f7 | ||
|
|
294055e055 | ||
|
|
036008d8a7 | ||
|
|
6df292f495 | ||
|
|
40c8d09f05 | ||
|
|
4a211b9092 | ||
|
|
24fc0b0e5f | ||
|
|
f58772ec33 | ||
|
|
eb830799c0 | ||
|
|
8df72ef343 | ||
|
|
63b2fc34d9 | ||
|
|
33d7324a24 | ||
|
|
62be61642d | ||
|
|
8cebbcf3be | ||
|
|
5c2a423658 | ||
|
|
1a9f025db6 | ||
|
|
a4667073f5 | ||
|
|
23d251e482 | ||
|
|
0c9dfb03e2 | ||
|
|
6dc928e0c3 | ||
|
|
84582ee33d | ||
|
|
116d6e6663 | ||
|
|
7d0d44aef9 | ||
|
|
3ac6c8532e | ||
|
|
f6a50c4fb4 | ||
|
|
c8113d7514 | ||
|
|
6159bc9ae1 | ||
|
|
3a11d0d1f1 | ||
|
|
535cf5753e | ||
|
|
cee4a6ccce | ||
|
|
1f1f95c2f8 | ||
|
|
f600e857b0 | ||
|
|
08d1708378 | ||
|
|
f1bc065730 | ||
|
|
b55782f066 | ||
|
|
2c9ae3489e | ||
|
|
b28b41c90b | ||
|
|
f34c4dc335 | ||
|
|
5a157197b6 | ||
|
|
8a711e0254 | ||
|
|
a22321daa2 | ||
|
|
fbf1bd18b1 | ||
|
|
dc371fc4eb | ||
|
|
97e4e78df4 | ||
|
|
007649296b | ||
|
|
9392aed402 | ||
|
|
32e417fe79 | ||
|
|
0e1f617401 | ||
|
|
bd6efaaa4e | ||
|
|
2b6dc528ce | ||
|
|
9368ad57b6 | ||
|
|
e97d338f3a | ||
|
|
a87a517b82 | ||
|
|
dbb52171c8 | ||
|
|
6c020318f9 | ||
|
|
e16994db3b | ||
|
|
8491a0b55d | ||
|
|
710e4da4c0 | ||
|
|
fd1f4e2ac2 | ||
|
|
70bf03a6e7 | ||
|
|
6f90d7a199 | ||
|
|
f0b1947c54 | ||
|
|
92193ff11f | ||
|
|
f9396b7283 | ||
|
|
7cf3e03433 | ||
|
|
0a61b4e78b | ||
|
|
1db4324c02 | ||
|
|
69529ee0db | ||
|
|
7a7ff47442 | ||
|
|
148771a82b | ||
|
|
5bad6a972f | ||
|
|
8bb7d59527 | ||
|
|
8732494442 | ||
|
|
cade75ac9b | ||
|
|
9abc9d1a19 | ||
|
|
f3fd8a30da | ||
|
|
b2324cd212 | ||
|
|
a1c8ba4cdd | ||
|
|
30461e7d95 | ||
|
|
46253fb80a | ||
|
|
29f2a4d9c9 | ||
|
|
e00e1e57da | ||
|
|
6668d778d2 | ||
|
|
7f3691aea3 | ||
|
|
1e280601c6 | ||
|
|
2262975785 | ||
|
|
e6e6e3ba9a | ||
|
|
e85cd9d53d | ||
|
|
828b5d9430 | ||
|
|
3b1ba72ddd | ||
|
|
4245ac04ac | ||
|
|
b8559c1221 | ||
|
|
6c4fa629a9 | ||
|
|
e6531d4357 | ||
|
|
0c27571030 | ||
|
|
6e6d9d2869 | ||
|
|
34529b2e79 | ||
|
|
7e4ac6542e | ||
|
|
3331dbd464 | ||
|
|
391b175a68 | ||
|
|
19f35b4200 | ||
|
|
9877bb79d0 | ||
|
|
2982e894b1 | ||
|
|
10bdf701e0 | ||
|
|
e764a10ee4 | ||
|
|
7e461e14a2 | ||
|
|
48cc706ad3 | ||
|
|
20ea616b59 | ||
|
|
73a7a38ea8 | ||
|
|
5c3fab5579 | ||
|
|
695862c61f | ||
|
|
bbc86e7b70 | ||
|
|
e35b57068b | ||
|
|
5faed55076 | ||
|
|
928d5fec16 | ||
|
|
ebcc4bb597 | ||
|
|
51541d3739 | ||
|
|
43b9200205 | ||
|
|
d5e9417d2d | ||
|
|
dec205d959 | ||
|
|
d87f3464fc | ||
|
|
25410cac92 | ||
|
|
66a1baef24 | ||
|
|
5c4df9cbd7 | ||
|
|
f89b3a888a | ||
|
|
bf545dda25 | ||
|
|
c706b4472d | ||
|
|
93b8041c3d | ||
|
|
89f9f9728f | ||
|
|
645409eddb | ||
|
|
58a763bcd1 | ||
|
|
fd91d72654 | ||
|
|
7fbf491e6f | ||
|
|
a8833de9d5 | ||
|
|
3a8b6394de | ||
|
|
f5d656650f | ||
|
|
dd314da16d | ||
|
|
47c1d4268f | ||
|
|
b9e87935cc | ||
|
|
73dd95bd41 | ||
|
|
c5bc358ef2 | ||
|
|
57c9eb9e1a | ||
|
|
a0c0544928 | ||
|
|
6dcae81a56 | ||
|
|
09baee6381 | ||
|
|
68aab718a1 | ||
|
|
5d6b2f3265 | ||
|
|
28730010c3 | ||
|
|
817ece40ba | ||
|
|
7c868e947a | ||
|
|
66984cd3d6 | ||
|
|
5fa508ba91 | ||
|
|
c1fca467bf | ||
|
|
e726c19311 | ||
|
|
6643642aa7 | ||
|
|
65dc3b5f4c | ||
|
|
c225fabbd4 | ||
|
|
fdcc2e301b | ||
|
|
183ab0780d | ||
|
|
81c69f243b | ||
|
|
6a3ecaff2a | ||
|
|
70f343d6fe | ||
|
|
172aa01e1c | ||
|
|
cba6ac8184 | ||
|
|
492f3d235a | ||
|
|
d9a01a3748 | ||
|
|
aab21ef91d | ||
|
|
2479e6a064 | ||
|
|
7cba26bb39 | ||
|
|
af0dc06f49 | ||
|
|
14900514b9 | ||
|
|
7faaef8cf0 | ||
|
|
c188257f85 | ||
|
|
4543a645f2 | ||
|
|
75d3b07e0f | ||
|
|
9da32c73cb | ||
|
|
102cef523f | ||
|
|
0e1be72f92 | ||
|
|
ef1afb9cc1 | ||
|
|
0462ddd6f5 | ||
|
|
8d7e70cde4 | ||
|
|
52e481128f | ||
|
|
c237e2433f | ||
|
|
a4bb57bb79 | ||
|
|
861cf5efb8 | ||
|
|
cf34f9fea9 | ||
|
|
92a6d30e26 | ||
|
|
23e8705b03 | ||
|
|
3240a4670a | ||
|
|
3bf83d40e9 | ||
|
|
c3368f553e | ||
|
|
27def94d8b | ||
|
|
2c139929ac | ||
|
|
b957f797da | ||
|
|
393500d565 | ||
|
|
a108837e82 | ||
|
|
859e88753f | ||
|
|
5160b44c4b | ||
|
|
17a9485fb2 | ||
|
|
d3c44f3d6c | ||
|
|
d49ba5281d | ||
|
|
c107c80bd8 | ||
|
|
cf90e775bf | ||
|
|
0a46096c41 | ||
|
|
289a44ae79 | ||
|
|
827e9bc343 | ||
|
|
f2ffaeb823 | ||
|
|
6f594f4b60 | ||
|
|
326be042f1 | ||
|
|
5b824fb745 | ||
|
|
cf6cb126b5 | ||
|
|
302df1edd2 | ||
|
|
e212509ed3 | ||
|
|
d3e3755d6d | ||
|
|
948ce6e529 | ||
|
|
d117dc231e | ||
|
|
eefa53e5bd | ||
|
|
db89fbfc52 | ||
|
|
97ec5d115c | ||
|
|
722a6cf50f | ||
|
|
d039dcfee5 | ||
|
|
d09aa11f2e | ||
|
|
8a2264546d | ||
|
|
0801e1dc44 | ||
|
|
a04d5fa5e0 | ||
|
|
e1280e8cc7 | ||
|
|
70884c2e30 | ||
|
|
917e95e3d7 | ||
|
|
27222bbb0d | ||
|
|
3720034464 | ||
|
|
de6cab1f9a | ||
|
|
2d1fbc2611 | ||
|
|
0afc70218f | ||
|
|
8d3abbc5f0 | ||
|
|
7ec14bc7db | ||
|
|
2843010a97 | ||
|
|
ad4e21ee61 | ||
|
|
4de39ab6f2 | ||
|
|
fbb646b8eb | ||
|
|
46f75ecd6f | ||
|
|
f68caa3bbd | ||
|
|
0109bf5b72 | ||
|
|
c69fb52d5c | ||
|
|
3a26f1f1c2 | ||
|
|
f1b94a5b69 | ||
|
|
e3fce9191a | ||
|
|
2c0a1b9bbb | ||
|
|
0d7417f858 | ||
|
|
2eee098c66 | ||
|
|
890d4b3c16 | ||
|
|
af9a7c6345 | ||
|
|
6fff1fd75f | ||
|
|
e6b7b08bda | ||
|
|
f4c3cbd53d | ||
|
|
c0c2f35266 | ||
|
|
ccea980753 | ||
|
|
2bddebd576 | ||
|
|
b9866ddbeb | ||
|
|
6fc79a7184 | ||
|
|
50039571b6 | ||
|
|
1216f20567 | ||
|
|
0752ea761c | ||
|
|
98461088f8 | ||
|
|
d4a3224aa3 | ||
|
|
197ce6faff | ||
|
|
c98e293879 | ||
|
|
ebbe382596 | ||
|
|
01d28eb2c5 | ||
|
|
7d1c2e7f0a | ||
|
|
22b499af20 | ||
|
|
7208f86723 | ||
|
|
2974a8b01c | ||
|
|
7f05a707bd | ||
|
|
1d056253ef | ||
|
|
a22dc5facb | ||
|
|
aeafd4ac79 | ||
|
|
14861df6b2 | ||
|
|
34a7595a6d | ||
|
|
b2108c0216 | ||
|
|
5a5a449210 | ||
|
|
0405de30bd | ||
|
|
40c2799b06 | ||
|
|
4bcdeefe68 | ||
|
|
b6ba6dd90e | ||
|
|
954f8b7b8d | ||
|
|
24d700673c | ||
|
|
c6d64029e0 | ||
|
|
40ff866598 | ||
|
|
36888e35d3 | ||
|
|
30432b931a | ||
|
|
e2174a14cf | ||
|
|
d3cb89d8c9 | ||
|
|
c8625b3396 | ||
|
|
b576f01d12 | ||
|
|
a87cafee3c | ||
|
|
1c51fde163 | ||
|
|
b05699db77 | ||
|
|
163e48ab27 | ||
|
|
0b41d992a4 | ||
|
|
c2ddfd6867 | ||
|
|
81b9dc1f06 | ||
|
|
5506ddb2c2 | ||
|
|
c74469b01a | ||
|
|
b706693f82 | ||
|
|
cd800148d5 | ||
|
|
900b0d7aa6 | ||
|
|
7e21c1f888 | ||
|
|
00d6f9c230 | ||
|
|
4c03080efa | ||
|
|
432a11d289 | ||
|
|
e9e233404b | ||
|
|
8101d42604 | ||
|
|
88cedd43bc | ||
|
|
d3a2934d9b | ||
|
|
13a36b6199 | ||
|
|
a155464d46 | ||
|
|
40d1c998bb | ||
|
|
b4837d49b6 | ||
|
|
e23042b42e | ||
|
|
240a733285 | ||
|
|
bb202be12c | ||
|
|
8c4cb601c9 | ||
|
|
64a7c7b7c4 | ||
|
|
2dd2887ff7 | ||
|
|
4bbe8a2789 | ||
|
|
3e9ceb673b | ||
|
|
9c4d1ef535 | ||
|
|
bf57518262 | ||
|
|
d48fb11de6 | ||
|
|
7ed87f760b | ||
|
|
ee37be16f0 | ||
|
|
a8cfaeefbd | ||
|
|
58cb8ee048 | ||
|
|
a72fbd1d47 | ||
|
|
f6408db74e | ||
|
|
df20993d8b | ||
|
|
708fe1bc4e | ||
|
|
e782fa832e | ||
|
|
c947c2c989 | ||
|
|
3de4613b7d | ||
|
|
def7290c48 | ||
|
|
1335b46404 | ||
|
|
0e15ac98a9 | ||
|
|
f127b2509d |
No files matched your search
+2
-2
@@ -2,9 +2,9 @@
|
||||
# docker-compose.full.yml up -d`. None of the defaults below are safe for
|
||||
# anything beyond a local laptop test.
|
||||
|
||||
# ── Public-facing ports (nginx) ---------------------------------------
|
||||
# ── Public-facing ports (Caddy) ---------------------------------------
|
||||
HTTP_PORT=80
|
||||
# HTTPS_PORT=443 # uncomment after you enable TLS in nginx/nginx.conf
|
||||
# HTTPS_PORT=443 # uncomment after you enable TLS in caddy/Caddyfile
|
||||
|
||||
# ── MariaDB ------------------------------------------------------------
|
||||
MARIADB_ROOT_PASSWORD=replace-with-strong-password
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
name: Backend Tests
|
||||
|
||||
# Runs on backend and extension changes. Tests are co-located with the
|
||||
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
|
||||
# changes match the same globs. puter.js SDK changes are covered by
|
||||
# puterjs-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '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:
|
||||
# `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
|
||||
# against tools/typecheck-baseline.json and fails only on *new* errors.
|
||||
#
|
||||
# The consuming repo runs an equivalent gate, but only once someone bumps the
|
||||
# submodule pointer — too late to keep the error off this repo's default
|
||||
# branch. Hence a gate here, on this repo's own pull requests.
|
||||
#
|
||||
# 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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check (new errors only)
|
||||
run: npm run typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
@@ -87,6 +87,8 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Docs Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/docs/**'
|
||||
|
||||
jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build
|
||||
working-directory: src/docs
|
||||
@@ -0,0 +1,172 @@
|
||||
name: Puter.js API Tests
|
||||
|
||||
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
|
||||
# so test changes are covered by the same glob — plus the pieces the
|
||||
# runners depend on: the worker preamble, the in-memory test env in
|
||||
# testUtil, and the backend vitest config the API-tests config extends.
|
||||
# Backend and extension changes run backend-tests.yaml instead.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'src/puter-js/**'
|
||||
- 'src/worker/**'
|
||||
- 'src/backend/testUtil.ts'
|
||||
- 'src/backend/vitest.config.ts'
|
||||
- 'tools/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/puterjs-tests.yaml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# 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
|
||||
# generates the declarations and type-checks the published surface without
|
||||
# skipLibCheck, which is how broken re-exports used to go unnoticed.
|
||||
#
|
||||
# Its own job rather than a step in `test`: that one builds bundles and
|
||||
# installs a browser, and this needs neither.
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check the puter.js JSDoc produces declarations that type-check
|
||||
run: npm run check:puterjs:types
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# The runners exercise the built artifacts: the SDK bundle
|
||||
# (src/puter-js/dist) for node + browser, and the worker preamble
|
||||
# (src/worker/dist) for workerd.
|
||||
- name: Build puter.js SDK and worker preamble
|
||||
run: npm run build:workerLib
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run puter.js API tests (node, browser, workerd)
|
||||
run: npm run test:puterjs
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
# SDK coverage on both refs, reported as a PR comment — same shape as
|
||||
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
|
||||
# (the `test` job above keeps exercising the production build).
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- ref: ${{ github.base_ref }}
|
||||
artifact: base
|
||||
- ref: ${{ github.head_ref }}
|
||||
artifact: pr
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.ref }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
# The base leg is best-effort — it only enriches the PR comment with
|
||||
# a comparison, and the base ref may predate the coverage script.
|
||||
- name: Run puter.js API tests with coverage
|
||||
continue-on-error: ${{ matrix.artifact == 'base' }}
|
||||
run: npm run test:puterjs:coverage
|
||||
env:
|
||||
CI: 'true'
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-${{ matrix.artifact }}
|
||||
path: src/puter-js/coverage/coverage-*.json
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
report-coverage:
|
||||
needs: coverage
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR coverage
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-pr
|
||||
path: src/puter-js/coverage
|
||||
|
||||
# May not exist (base ref without the coverage script, or a failed
|
||||
# base run) — then report without the comparison column.
|
||||
- name: Download base coverage
|
||||
id: base-coverage
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: puterjs-coverage-base
|
||||
path: coverage-base
|
||||
|
||||
- name: Report coverage on PR
|
||||
if: steps.base-coverage.outcome == 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
json-summary-compare-path: coverage-base/coverage-summary.json
|
||||
|
||||
- name: Report coverage on PR (no base comparison)
|
||||
if: steps.base-coverage.outcome != 'success'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
name: puter.js SDK
|
||||
json-summary-path: src/puter-js/coverage/coverage-summary.json
|
||||
json-final-path: src/puter-js/coverage/coverage-final.json
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-jsdoc"]
|
||||
}
|
||||
@@ -8,6 +8,9 @@ Use these as the source of truth before exploring further:
|
||||
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
|
||||
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
|
||||
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
|
||||
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
|
||||
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
|
||||
@@ -16,70 +19,21 @@ Use these as the source of truth before exploring further:
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
## Repo-wide conventions
|
||||
|
||||
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
|
||||
These apply everywhere — backend, puter.js, and GUI.
|
||||
|
||||
### Layers (top → bottom)
|
||||
### Language & files
|
||||
|
||||
| Layer | Lives in | Responsibility |
|
||||
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
|
||||
|
||||
### Cross-layer rules
|
||||
|
||||
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
|
||||
|
||||
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
|
||||
|
||||
### Language & file conventions
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
- Write ES modules, not CommonJS — we transpile and build as needed.
|
||||
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them. In puter.js, type with JSDoc instead (see the puter.js section for what must be typed).
|
||||
- **Reuse before adding.** Search for an existing type, helper, or implementation and extend it; only add a new one when nothing suitable exists.
|
||||
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places belongs in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)) rather than bloating a logic file; a type with a single consumer can stay next to it.
|
||||
- Naming: `camelCase` for variables/functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code** — new local variables, parameters, functions, and internal object properties. `snake_case` is reserved for identifiers that are part of an external contract: wire/JSON keys sent to or received from the backend, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), but don't introduce new `snake_case` alongside them either — a camelCase local can carry a value into a `snake_case` wire key (`{ operation_id: operationId }`).
|
||||
|
||||
### Comments
|
||||
|
||||
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
|
||||
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the *why* in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
|
||||
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
|
||||
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
|
||||
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
|
||||
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
|
||||
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
|
||||
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
|
||||
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
|
||||
|
||||
### Security & privacy
|
||||
|
||||
@@ -94,6 +48,105 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
### Working rules of thumb
|
||||
|
||||
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
|
||||
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you think the existing pattern is wrong, raise it — don't quietly diverge.
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
| Layer (top → bottom) | Lives in | Responsibility |
|
||||
| -------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers: parse/validate input, per-route gates via `RouteOptions` (auth, subdomain, rate limit, body parsers), call services, format responses. |
|
||||
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional RPC-style handlers on `/drivers/*`; thin shells that validate inputs and call services/stores. |
|
||||
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized. |
|
||||
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence; wraps clients in the domain shapes services consume. |
|
||||
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, email, event bus). Protocols, not domain concepts. |
|
||||
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
|
||||
|
||||
A public API can be exposed through either a controller or a driver — both are supported; prefer a controller when you need fine-grained control over routes and gates. [doc/contributing-apis.md](doc/contributing-apis.md) has the decision guide with links to the decorators and middleware for each.
|
||||
|
||||
Cross-layer rules:
|
||||
|
||||
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes. If you want to, the abstraction is wrong — fix the abstraction.
|
||||
- **Don't call sideways within a layer for code reuse.** Two services needing the same logic means a util/helper, not a service-to-service dependency.
|
||||
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only for genuinely request-scoped values that would otherwise thread through many layers — today mostly `actor` and `req`.
|
||||
|
||||
### Extensions
|
||||
|
||||
[extensions/](extensions/) parallels the layered stack and is for **non-crucial parts of the system** — Puter still works with the extension removed. If core needs to call it, it belongs in core, not an extension (see [whoami](extensions/whoami.ts) as the cautionary example). The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
|
||||
|
||||
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
|
||||
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
|
||||
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` — lazy proxy to instantiated core objects. `extension.config` exposes live config.
|
||||
|
||||
Follow the same layered structure inside an extension — unless it only needs a few route handlers, in which case the lightweight helpers are enough on their own.
|
||||
|
||||
### Backend tests
|
||||
|
||||
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
|
||||
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
|
||||
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
|
||||
|
||||
---
|
||||
|
||||
## puter.js (the SDK)
|
||||
|
||||
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
|
||||
|
||||
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `kv/`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), generated (gitignored) type declarations in `src/puter-js/types/`, API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
|
||||
|
||||
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. **The JSDoc is the source of truth for the SDK's types.** `src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it so TypeScript consumers get declarations, and git ignores it — it is never committed and never edited by hand. `npm run check:puterjs:types` generates it in CI and type-checks the result without `skipLibCheck`. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts), which decides what is public and re-exports the generated names.
|
||||
|
||||
Declare a shape where it belongs, and reference it with an `import(...)` type from elsewhere. A shape more than one file needs lives in the module's `types.js`; a shape shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); a shape with one consumer can stay next to it:
|
||||
|
||||
```js
|
||||
/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */
|
||||
```
|
||||
|
||||
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 */
|
||||
```
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
/** @type {KVEntry[]} */
|
||||
let entries = [];
|
||||
```
|
||||
|
||||
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
|
||||
|
||||
1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
|
||||
2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
|
||||
3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
|
||||
4. **Types.** Type the change in JSDoc on the implementation, then run `npm run check:puterjs:types` to confirm it still produces declarations that type-check. Name any new type in [src/puter-js/index.d.ts](src/puter-js/index.d.ts) if consumers should be able to import it. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
|
||||
5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
|
||||
|
||||
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
|
||||
|
||||
---
|
||||
|
||||
## GUI
|
||||
|
||||
[src/gui/](src/gui/) is the Puter desktop: deliberately plain JavaScript + jQuery with HTML-string templates. Don't introduce a UI framework or a new rendering pattern.
|
||||
|
||||
The guiding rule here is **conformity over novelty** — match the existing design and code structure even where you'd personally choose differently. A visually or structurally divergent addition is a defect even when it works.
|
||||
|
||||
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
|
||||
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
|
||||
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
|
||||
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
|
||||
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
|
||||
+39
-35
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
- **`puter.com`**
|
||||
- **`api.puter.com`**
|
||||
|
||||
Out-of-scope:
|
||||
|
||||
* Third-party services, applications, or libraries not maintained by Puter.
|
||||
* Social engineering attacks (e.g., phishing against staff).
|
||||
* Denial of Service (DoS), spam, or volumetric attacks.
|
||||
* Physical security issues.
|
||||
- Third-party services, applications, or libraries not maintained by Puter.
|
||||
- Social engineering attacks (e.g., phishing against staff).
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
|
||||
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
* **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
|
||||
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
|
||||
|
||||
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
|
||||
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
@@ -51,26 +51,30 @@ Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
## Proof of Reproduction
|
||||
|
||||
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
|
||||
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
* The **observed** result versus the **expected** result.
|
||||
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
* For server-side bugs: a runnable proof-of-concept.
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
Include:
|
||||
|
||||
* A description of the vulnerability
|
||||
* Steps to reproduce
|
||||
* Potential impact
|
||||
* Suggested remediation (if available)
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
@@ -78,10 +82,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
|
||||
|
||||
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
|
||||
|
||||
* **Critical: \$1,000 – \$2,000**
|
||||
* **High: \$500 – \$1,000**
|
||||
* **Medium: \$200 – \$500**
|
||||
* **Low: \$50 – \$100**
|
||||
- **Critical: \$1,000 – \$2,000**
|
||||
- **High: \$500 – \$1,000**
|
||||
- **Medium: \$200 – \$500**
|
||||
- **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
|
||||
+4
-2
@@ -16,6 +16,8 @@ Add tests for new behavior, endpoints, or bug fixes. If something's genuinely ha
|
||||
|
||||
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
|
||||
|
||||
In plain-JS files, typing is encouraged via JSDoc `@type` annotations using the TypeScript type system, with `@typedef` for shared shapes. Don't type API surfaces as `unknown` or untyped `...args` unless the values are passed through transparently to an upstream layer that owns their type.
|
||||
|
||||
## 3. Don't expose system or user information
|
||||
|
||||
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
|
||||
@@ -26,9 +28,9 @@ For private security reports, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
|
||||
|
||||
## 5. Update docs for API changes
|
||||
## 5. Adding or changing APIs
|
||||
|
||||
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
|
||||
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
|
||||
|
||||
## 6. Boy Scout Rule — leave it 1% better
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Reverse proxy in front of Puter — mirrors what the prod ALB does:
|
||||
# accepts every Host header, forwards to the Puter container, and lets
|
||||
# the Puter app handle subdomain-based routing internally (api.*,
|
||||
# site.*, app.*, dev.*, plus the per-user subdomains under those).
|
||||
#
|
||||
# To enable TLS:
|
||||
# 1. Drop a wildcard fullchain.pem + privkey.pem into ./puter/tls/
|
||||
# (see "Step 3 — TLS" in doc/self-hosting.md).
|
||||
# 2. Uncomment the `:443` block at the bottom of this file, and swap the
|
||||
# `:80` block for the redirect shown alongside it.
|
||||
# 3. Uncomment the `443:443` port mapping under the `caddy` service in
|
||||
# docker-compose.yml, and HTTPS_PORT in .env.
|
||||
# 4. Set `"protocol": "https"` and `"pub_port": 443` in config.json.
|
||||
{
|
||||
# Certs are supplied by the operator, not issued by Caddy. Puter serves
|
||||
# per-user sites and apps on dynamic subdomains (<name>.site.<domain>,
|
||||
# <name>.app.<domain>) — only a DNS-01 wildcard cert covers those, and
|
||||
# DNS-01 needs a provider plugin that isn't in the stock caddy image.
|
||||
# `off` stops Caddy attempting ACME on boot (it would fail and leave the
|
||||
# site unreachable) and stops it inventing its own http→https redirects.
|
||||
auto_https off
|
||||
}
|
||||
|
||||
# Shared handling, imported by the HTTP and HTTPS site blocks below so the
|
||||
# two can't drift apart.
|
||||
(puter_routes) {
|
||||
# Rough size cap that mirrors prod ALB defaults; tune for your uploads.
|
||||
# Puter chunks large uploads, so 1 GiB per request is plenty.
|
||||
request_body {
|
||||
max_size 1024MiB
|
||||
}
|
||||
|
||||
# RustFS — see the `s3` service in docker-compose.yml. Browsers PUT/GET
|
||||
# here for presigned-URL uploads / downloads. Matched on the `s3.`
|
||||
# subdomain of whatever the install domain is, so signature verification
|
||||
# works (Caddy preserves the original Host end-to-end) and HTTPS stays
|
||||
# clean — no mixed content from a port-9000 host publish.
|
||||
@s3 header_regexp Host ^s3\.
|
||||
handle @s3 {
|
||||
reverse_proxy s3:9000
|
||||
}
|
||||
|
||||
# Everything else, on every Host, goes to Puter — which routes on that
|
||||
# Host internally. Caddy forwards it unchanged and adds X-Forwarded-For
|
||||
# / -Proto / -Host, which is what `trust_proxy` in config.json counts.
|
||||
handle {
|
||||
reverse_proxy puter:4100 {
|
||||
# Stream responses through unbuffered — Puter uses SSE and
|
||||
# socket.io. WebSocket upgrades need no config of their own;
|
||||
# Caddy proxies them by default.
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── HTTP (port 80) ─────────────────────────────────────────────────────
|
||||
# A site address with no hostname is the catch-all: it answers for every
|
||||
# Host, which is what Puter's subdomain routing requires.
|
||||
:80 {
|
||||
import puter_routes
|
||||
}
|
||||
|
||||
# ── HTTPS (port 443) — uncomment after dropping certs in ./puter/tls/ ──
|
||||
# Replace the `:80` block above with a redirect to force HTTPS everywhere:
|
||||
#
|
||||
# :80 {
|
||||
# redir https://{host}{uri} permanent
|
||||
# }
|
||||
#
|
||||
# :443 {
|
||||
# tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
|
||||
# import puter_routes
|
||||
# }
|
||||
+1
-2
@@ -5,9 +5,7 @@
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"jwt_secret": "dev-jwt-secret-change-me",
|
||||
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "dev-url-signature-secret-change-me",
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
@@ -23,6 +21,7 @@
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
+92
-6
@@ -16,6 +16,12 @@
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
// Console output format. `json` replaces the global console so every call
|
||||
// emits one structured JSON line (level, timestamp, msg, and the active
|
||||
// request's trace id) — one event per call, so a line-oriented log
|
||||
// collector can't split stack traces across events and level filtering
|
||||
// works. Unset (the default) leaves console output human-readable.
|
||||
"log_format": "text",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
@@ -67,14 +73,13 @@
|
||||
|
||||
// ── Auth / session ──────────────────────────────────────────────────
|
||||
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
|
||||
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
|
||||
"jwt_secret": "change-me",
|
||||
"jwt_secret_v2": "change-me",
|
||||
// Set false to retire v1 tokens entirely (ROLLOUT-1).
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
// When true, anonymous users must log in instead of creating temp or
|
||||
// permanent accounts.
|
||||
"disable_user_signup": false,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
@@ -246,6 +251,60 @@
|
||||
}
|
||||
},
|
||||
|
||||
// ── Alarms / alerting ───────────────────────────────────────────────
|
||||
// Where system alarms go. Severity is the routing decision — each
|
||||
// transport takes everything at or above its own `minSeverity`:
|
||||
//
|
||||
// critical — an unhandled server error; pages on-call.
|
||||
// error — pages as well; prefer critical or warning.
|
||||
// warning — look at it today; no page.
|
||||
// info — a record in the chat channel only.
|
||||
//
|
||||
// Both transports are off unless enabled, so a self-hosted node just
|
||||
// logs its alarms to the console.
|
||||
"pager": {
|
||||
// Severity for call sites that don't pick one. Default "critical".
|
||||
"defaultSeverity": "critical",
|
||||
|
||||
// Retier or silence an alarm without a deploy. Keys are alarm ids,
|
||||
// or a prefix ending in `*`; the exact id wins over a pattern, and
|
||||
// the longest matching pattern wins among patterns. Values are a
|
||||
// severity or "mute". This is applied last, so it overrides both the
|
||||
// call site and any known-error rule.
|
||||
"severityOverrides": {
|
||||
// "cronMonitor:*": "info",
|
||||
// "http_500:GET:/some/flapping/route:*": "mute"
|
||||
},
|
||||
|
||||
"pagerduty": {
|
||||
"enabled": false,
|
||||
"routingKey": "",
|
||||
// Lowest severity that reaches PagerDuty. Default "warning",
|
||||
// which keeps `info` out of the paging system entirely.
|
||||
"minSeverity": "warning"
|
||||
},
|
||||
|
||||
// Slack incoming webhook — the low-noise destination for everything
|
||||
// that shouldn't page.
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"webhookUrl": "",
|
||||
// Optional; defaults to the channel the webhook was created for.
|
||||
"channel": "#alerts",
|
||||
"username": "puter-alarms",
|
||||
// Severity window posted to Slack. The ceiling defaults to
|
||||
// "info" when PagerDuty is configured — what pages belongs in
|
||||
// the pager, not in chat — and to "critical" when Slack is the
|
||||
// only transport.
|
||||
"minSeverity": "info",
|
||||
"maxSeverity": "info",
|
||||
// Don't repost the same alarm id within this window. The first
|
||||
// occurrence always posts; the next post that gets through
|
||||
// reports how many occurrences piled up. 0 disables throttling.
|
||||
"repeatThrottleMs": 900000
|
||||
}
|
||||
},
|
||||
|
||||
// ── Rate limiting ───────────────────────────────────────────────────
|
||||
// `memory` for single-node, `redis` for multi-node (default), `kv` for
|
||||
// dynamo-backed counters.
|
||||
@@ -260,6 +319,10 @@
|
||||
// ─ Chat / completion ─
|
||||
"claude": { "apiKey": "" },
|
||||
"openai-completion": { "apiKey": "" },
|
||||
"azure-openai": {
|
||||
"apiKey": "",
|
||||
"apiURL": ""
|
||||
},
|
||||
"gemini": { "apiKey": "" },
|
||||
"groq": { "apiKey": "" },
|
||||
"deepseek": { "apiKey": "" },
|
||||
@@ -274,6 +337,10 @@
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://openrouter.ai/api/v1"
|
||||
},
|
||||
"infron": {
|
||||
"apiKey": "",
|
||||
"apiBaseUrl": "https://llm.onerouter.pro/v1"
|
||||
},
|
||||
"together-ai": { "apiKey": "" },
|
||||
// Local Ollama. `enabled: false` skips the auto-probe at startup
|
||||
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
|
||||
@@ -312,6 +379,7 @@
|
||||
"secret_key": "",
|
||||
"region": "us-west-2"
|
||||
},
|
||||
"speechify": { "apiKey": "" },
|
||||
"aws-textract": {
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
@@ -369,6 +437,24 @@
|
||||
],
|
||||
|
||||
// ── Metering ────────────────────────────────────────────────────────
|
||||
// When true, all metering checks pass — no per-actor limits enforced.
|
||||
"unlimitedMetering": false
|
||||
// When true, every account resolves to an unlimited policy: usage is still
|
||||
// recorded, but nothing is ever refused for lack of budget. This is the
|
||||
// setting for a deployment with no way to buy more — without it, accounts
|
||||
// are held to the free monthly allowance and start getting 402s from the AI
|
||||
// surfaces, file transfers and KV once they pass it.
|
||||
"unlimitedMetering": false,
|
||||
|
||||
// Whether an account that has spent its whole allowance is refused the
|
||||
// operations that spend it — file transfers, KV calls. Recording is
|
||||
// unaffected either way. `workers` extends the same refusal to
|
||||
// worker-driven calls, which are exempt by default because a deployed
|
||||
// worker has nowhere to surface a payment prompt.
|
||||
// "meteringEnforcement": { "enabled": true, "workers": false },
|
||||
|
||||
// Fleet-wide spend rate, in micro-cents per minute, past which metering
|
||||
// raises the `metering:excessiveGlobalUsageRate` alarm. Omit it (the
|
||||
// default) to leave the check off: the only useful value is a multiple of
|
||||
// what this deployment's normal traffic costs, so it has to be measured
|
||||
// rather than guessed, and a stale number here alarms on healthy growth.
|
||||
// "maxGlobalUsagePerMinute": 200000000
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Alarms
|
||||
|
||||
`clients.alarm` is the one way code reports that something is wrong. It
|
||||
de-dupes by alarm id, counts occurrences, and routes each alarm to alert
|
||||
transports by **severity**.
|
||||
|
||||
```ts
|
||||
this.clients.alarm.create(
|
||||
`metering_write_failed:${userUuid}`, // de-dupe key
|
||||
'Metering write failed', // what a human reads
|
||||
{ userUuid, appId, error }, // context fields
|
||||
'info', // severity
|
||||
);
|
||||
```
|
||||
|
||||
## Severity is the routing decision
|
||||
|
||||
| Severity | Meaning | Goes to |
|
||||
| ---------- | ------------------------------------------------------ | ----------- |
|
||||
| `critical` | An unhandled server error. Someone gets woken up. | Pager |
|
||||
| `error` | Same urgency as critical; prefer one of the other two. | Pager |
|
||||
| `warning` | Worth a look today. Nobody is paged. | Pager (low) |
|
||||
| `info` | A record of something expected-but-notable. | Chat |
|
||||
|
||||
Each transport declares the severity window it accepts, so the value a call
|
||||
site passes is what decides where the alarm lands. The two windows don't
|
||||
overlap by default: anything that pages lives in the paging system, and chat
|
||||
is the record of what didn't. The bar for `critical` is
|
||||
deliberately high: an unhandled 5xx out of the HTTP error handler is the main
|
||||
thing that still pages. Anything a human can look at tomorrow is `warning`,
|
||||
and anything that's just worth recording is `info`.
|
||||
|
||||
Omitting the severity takes `pager.defaultSeverity` (itself `critical`), so
|
||||
pass one explicitly unless you really mean "page someone".
|
||||
|
||||
### Choosing one
|
||||
|
||||
- Did the server fail to do its job in a way nobody expected? → `critical`
|
||||
- Is a background job, rate, or dependency degraded? → `warning`
|
||||
- Is this a user doing something notable (tripping an abuse heuristic,
|
||||
overspending)? → `info`
|
||||
- Did one of *our own* limits reject a caller — a rate limit, a concurrency
|
||||
cap, a quota? → don't alarm at all. The limit doing its job is not an
|
||||
event; the 429 is the whole signal, and alarming on it only produces noise
|
||||
proportional to traffic. An *upstream provider* rate-limiting us is the
|
||||
opposite case and still alarms (`upstream_rate_limited`, `info`) — that one
|
||||
is not something we chose.
|
||||
|
||||
An extension whose signals are all one tier can default its own local
|
||||
`raiseAlarm` helper to that tier instead of repeating it at every call site —
|
||||
see [extensions/cronMonitor](../../../extensions/cronMonitor/index.js).
|
||||
|
||||
## One incident per occurrence, unless you say otherwise
|
||||
|
||||
Alarms always de-dupe *in process* — repeats of an id bump its occurrence
|
||||
count rather than creating a second alarm. What that means for the pager is a
|
||||
separate decision, and by default every occurrence opens its own PagerDuty
|
||||
incident: two failed scans an hour apart are two things that happened, and
|
||||
closing one shouldn't hide the other.
|
||||
|
||||
Pass `{ dedup: true }` as a fifth argument when repeats of the id really are
|
||||
one recurring fault, and they collapse onto a single incident carrying the
|
||||
occurrence count:
|
||||
|
||||
```ts
|
||||
this.clients.alarm.create(alarmId, message, fields, 'critical', {
|
||||
dedup: true,
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
Reach for it anywhere else only when the id is that specific — otherwise a
|
||||
per-request alarm can flood the pager.
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything lives under `pager` in config (see
|
||||
[config.template.jsonc](../config.template.jsonc) for the annotated version).
|
||||
Both transports are off unless enabled, so a self-hosted node just logs
|
||||
alarms to the console.
|
||||
|
||||
```jsonc
|
||||
"pager": {
|
||||
"defaultSeverity": "critical",
|
||||
"severityOverrides": { "cronMonitor:*": "info" },
|
||||
"pagerduty": { "enabled": true, "routingKey": "…", "minSeverity": "warning" },
|
||||
"slack": {
|
||||
"enabled": true,
|
||||
"webhookUrl": "…",
|
||||
"channel": "#alerts",
|
||||
"minSeverity": "info",
|
||||
"maxSeverity": "info",
|
||||
"repeatThrottleMs": 900000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Slack's `maxSeverity` defaults to `info` whenever PagerDuty is configured, and
|
||||
to `critical` when it isn't — a node with only a webhook still sees
|
||||
everything. Raise it to have chat mirror the paging tiers as well.
|
||||
|
||||
### Retiering without a deploy
|
||||
|
||||
`severityOverrides` is the escape hatch for an alarm that turns out to be
|
||||
noisier or more serious than its call site assumed. Keys are alarm ids or a
|
||||
prefix ending in `*`; the exact id beats a pattern, and the longest matching
|
||||
prefix wins among patterns. Values are a severity, or `mute` to drop the
|
||||
alarm before any transport sees it.
|
||||
|
||||
It is applied *after* the call site's severity and any known-error rule, so
|
||||
config always has the last word.
|
||||
|
||||
### Repeat throttling
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Contributing APIs
|
||||
|
||||
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, `/drivers/*` methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — [AGENTS.md](../AGENTS.md) defers to it for API work.
|
||||
|
||||
Companion docs: [architecture.md](architecture.md) (backend layering), [pagination.md](pagination.md) (list APIs), [src/puter-js/tests/api/README.md](../src/puter-js/tests/api/README.md) (SDK test environment).
|
||||
|
||||
## Rule zero: don't break callers
|
||||
|
||||
puter.js is served live from `https://js.puter.com/v2/` with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
|
||||
|
||||
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
|
||||
|
||||
## Core or extension?
|
||||
|
||||
The first decision is where the API lives.
|
||||
|
||||
If the API is **not crucial to core functionality — nothing in core will call it — prefer an extension** over wiring it into core. Extensions live in [extensions/](../extensions/), parallel the core layered stack, and reach core through `extension.import(...)`:
|
||||
|
||||
```js
|
||||
const services = extension.import('service');
|
||||
const stores = extension.import('store');
|
||||
|
||||
extension.registerDriver('myFeature', MyFeatureDriver); // first-class driver
|
||||
extension.post('/my-feature/frobnicate', opts, handler); // or plain routes
|
||||
```
|
||||
|
||||
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight `extension.get/post/...` helpers are enough on their own.
|
||||
|
||||
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see [whoami](../extensions/whoami.ts) for the cautionary example of a load-bearing "extension".
|
||||
|
||||
## Adding a new API
|
||||
|
||||
Work through all seven steps; the PR is complete when every one is.
|
||||
|
||||
### 1. Design the surface first
|
||||
|
||||
- Sketch the signature, options, return shape, and error cases before writing code. Find the two or three most similar existing APIs and match their conventions.
|
||||
- New parameter and field names are `camelCase`. (Existing `snake_case` names stay where they already exist.)
|
||||
- Anything returning a list follows the [pagination convention](pagination.md): `limit`/`cursor` in, `{ items, cursor, total }` envelope out.
|
||||
- Prefer an options object over a growing list of positional parameters, but keep the common case callable with a single argument where siblings do.
|
||||
|
||||
### 2. Backend
|
||||
|
||||
Follow the layered stack ([architecture.md](architecture.md)): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
|
||||
|
||||
#### Controller or driver?
|
||||
|
||||
Both are supported ways to define an API. **Prefer a controller when you need fine-grained control** — URL shape, HTTP verbs, per-route gates, response and streaming formats. A **driver** fits when the API is a set of RPC methods implementing one of the named interfaces on `/drivers/*` (`puter-kvstore`, `puter-chat-completion`, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
|
||||
|
||||
- **Controller:** extend `PuterController` ([src/backend/controllers/types.ts](../src/backend/controllers/types.ts)) and declare routes with the `@Controller(prefix)` class decorator plus `@Get`/`@Post`/… method decorators ([src/backend/core/http/decorators.ts](../src/backend/core/http/decorators.ts)), each taking `(path, routeOptions)` — or override `registerRoutes(router)` imperatively. Core controllers register in [src/backend/controllers/index.ts](../src/backend/controllers/index.ts); extensions use `extension.registerController(...)` or the plain route helpers.
|
||||
- **Driver:** extend `PuterDriver` ([src/backend/drivers/types.ts](../src/backend/drivers/types.ts)) and mark it with the `@Driver(interfaceName, opts)` decorator ([src/backend/drivers/decorators.ts](../src/backend/drivers/decorators.ts)), which also declares its policies. Core drivers register in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts); extensions use `extension.registerDriver(...)`.
|
||||
|
||||
#### Middleware and gates
|
||||
|
||||
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
|
||||
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
|
||||
- **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.
|
||||
|
||||
### 3. puter.js
|
||||
|
||||
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
|
||||
- Validate cheap preconditions client-side and throw `{ message, code }` objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.
|
||||
|
||||
### 4. Types
|
||||
|
||||
- Type the method where you wrote it, in JSDoc: `@param`/`@returns` on the implementation, one `@overload` block per accepted call form, and `@typedef {Object}` + `@property` for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand.
|
||||
- Put a shape more than one file needs in the module's `types.js` (e.g. [src/puter-js/src/modules/kv/types.js](../src/puter-js/src/modules/kv/types.js)); anything shared across modules goes in [src/puter-js/src/lib/types.js](../src/puter-js/src/lib/types.js). A shape with one consumer can stay next to it.
|
||||
- Run `npm run check:puterjs:types` — it generates the declarations and type-checks the published surface without `skipLibCheck`. **Never edit anything under `src/puter-js/types/`**: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it.
|
||||
- Name the new type in [src/puter-js/index.d.ts](../src/puter-js/index.d.ts) if consumers should be able to import it. That file is the one hand-written declaration in the package: it decides what is public and re-exports nothing else.
|
||||
|
||||
### 5. Docs
|
||||
|
||||
- Add the method page at [src/docs/src/](../src/docs/src/)`<Area>/<method>.md` — frontmatter (`title`, `description`, `platforms`), syntax, parameters, return value, and at least one runnable example — and update the area overview (`<Area>.md`). Copy the structure of an existing page.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- **Backend:** colocated Vitest tests; prefer the in-memory test server (`setupPuterTestEnv` in [src/backend/testUtil.ts](../src/backend/testUtil.ts)) over mocking.
|
||||
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`). One suite runs on node, browser, and workerd via `npm run test:puterjs` — never write per-platform tests, and rebuild first with `npm run build:workerLib` since the runners execute the built bundle.
|
||||
- **Desktop-rendered UI** (`puter.ui.*`): add a Playwright spec per [src/puter-js/TESTING.md](../src/puter-js/TESTING.md).
|
||||
|
||||
### 7. Security pass
|
||||
|
||||
Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
|
||||
|
||||
## Maintaining an existing API
|
||||
|
||||
Changes are **additive by default**:
|
||||
|
||||
- New parameters are optional, with defaults that reproduce the old behavior exactly.
|
||||
- Never rename, repurpose, or remove existing parameters, response fields, or error codes. Don't change types, ordering guarantees, or which fields are present when.
|
||||
- New behavior that could surprise existing callers goes behind an opt-in flag.
|
||||
- Docs, types, and tests move in the same PR as the behavior. A signature change with stale docs is a bug — the docs are the contract users code against.
|
||||
- Bug fixes come with a regression test that fails before the fix. Be suspicious of fixes that change observable behavior: someone may depend on the bug. When in doubt, ask a maintainer.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
|
||||
|
||||
### Deprecating
|
||||
|
||||
The old surface keeps working. Mark it `@deprecated` in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] Backward compatible (or the break was explicitly approved)
|
||||
- [ ] Right home: extension if core never calls it; layered structure either way
|
||||
- [ ] puter.js method matches sibling conventions; errors are `{ message, code }` with stable codes
|
||||
- [ ] Types updated and matching runtime behavior
|
||||
- [ ] Docs page + area overview updated, with a runnable example
|
||||
- [ ] Tests: backend + three-platform SDK suite (+ e2e for desktop-rendered UI)
|
||||
- [ ] Security pass on the diff
|
||||
- [ ] You ran it end to end
|
||||
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
|
||||
|
||||
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
|
||||
|
||||
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
|
||||
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
|
||||
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Pagination convention for list APIs
|
||||
|
||||
Every list endpoint follows one wire contract. This document is the source of
|
||||
truth for adding pagination to a new or existing list surface.
|
||||
|
||||
## Wire contract
|
||||
|
||||
Requests accept:
|
||||
|
||||
| Param | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `limit` | number | Maximum items per page. Each endpoint documents its cap and default. |
|
||||
| `cursor` | string \| null | Opaque continuation token. `null` (or any presence of the key) requests the first page. |
|
||||
| `offset` | number | Skip N items. Legacy/discouraged — see below. Cannot be combined with `cursor`. |
|
||||
| `includeTotal` | boolean | Adds `total` to the response. |
|
||||
|
||||
Paginated responses are an envelope:
|
||||
|
||||
```json
|
||||
{ "items": [...], "cursor": "…", "total": 123 }
|
||||
```
|
||||
|
||||
- `cursor` is present only while more pages exist. Clients iterate until it
|
||||
is absent.
|
||||
- `total` is present only when the request set `includeTotal`.
|
||||
- **Pages may be short.** Post-query filtering (TTL expiry, permission
|
||||
checks) can shrink a page below `limit` — or even to zero — while a
|
||||
`cursor` is still returned. Never use `items.length < limit` as an
|
||||
end-of-list signal.
|
||||
|
||||
New request params are camelCase (`includeTotal`, `fetchUntilFull`).
|
||||
Pre-existing snake_case params stay for compatibility.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Requests without pagination params keep returning the full result in the
|
||||
legacy shape (bare array) forever — old clients never break.
|
||||
|
||||
The envelope trigger depends on the endpoint's history:
|
||||
|
||||
- Endpoints where `limit` pre-dates the convention (readdir, subdomain
|
||||
`select`) return the envelope only when the request contains `cursor`
|
||||
(including `null`) or `includeTotal`; `limit`/`offset`-only requests keep
|
||||
the bare array.
|
||||
- Endpoints where every pagination param is new (kv `list`, workers
|
||||
`getFilePaths`) return the envelope when any pagination param is present.
|
||||
|
||||
## Cursors
|
||||
|
||||
Cursors are opaque base64-encoded JSON, produced and consumed only by the
|
||||
backend (`src/backend/util/pagination.ts`). What a cursor wraps is an
|
||||
implementation detail per store:
|
||||
|
||||
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
|
||||
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
|
||||
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
|
||||
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
|
||||
ending in a unique tiebreaker (`id`).
|
||||
|
||||
Cursors that carry a sort also pin it: a request that passes a cursor plus a
|
||||
conflicting sort is rejected with 400.
|
||||
|
||||
## Offset
|
||||
|
||||
SQL-backed endpoints support `offset` natively. DynamoDB-backed endpoints
|
||||
(kv) emulate it by advancing past skipped items with `Select: COUNT` queries
|
||||
— no item data is transferred, but read capacity is still consumed for
|
||||
everything skipped, and the caller is metered for it. **Offset is supported
|
||||
for parity, not recommended** — cost grows linearly with the offset, so it is
|
||||
capped (kv: 5000). Use cursors.
|
||||
|
||||
## Totals (`includeTotal`)
|
||||
|
||||
- SQL: `SELECT COUNT(*)` with the same WHERE clause as the listing.
|
||||
- DynamoDB: a `Select: COUNT` loop over the query (TTL-filtered), metered to
|
||||
the caller. Cost is proportional to the total item count — request the
|
||||
total on the first page only, not every page.
|
||||
- Where visibility is decided per-actor after the query (protected apps in
|
||||
the catalog listing), `total` approximates the visible set: it counts
|
||||
non-protected plus caller-owned rows, and misses rows visible only through
|
||||
explicit permission grants.
|
||||
|
||||
## fetchUntilFull (kv only)
|
||||
|
||||
DynamoDB applies `Limit` before its filter expression, so TTL-filtered pages
|
||||
are structurally short. `fetchUntilFull: true` makes the backend keep
|
||||
fetching (bounded number of continuation queries) until the page holds
|
||||
`limit` items or the keyset is exhausted. Requires `limit`. If the bound is
|
||||
hit, the response simply carries a cursor — still convention-legal.
|
||||
|
||||
## Adding pagination to a new endpoint
|
||||
|
||||
1. Use `encodeCursor`/`decodeCursor`/`normalizeLimit`/`normalizeOffset` from
|
||||
`src/backend/util/pagination.ts`.
|
||||
2. Push equality filters into the query so pages and counts operate on the
|
||||
true result set; only genuinely per-actor filtering may remain post-query
|
||||
(short-pages rule covers it).
|
||||
3. Fetch `limit + 1` rows to detect whether a next page exists (SQL), or use
|
||||
`LastEvaluatedKey` (DynamoDB).
|
||||
4. Keep the no-params request returning the legacy full result.
|
||||
+85
-18
@@ -1,6 +1,6 @@
|
||||
# Self-Hosting Puter
|
||||
|
||||
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
|
||||
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, Caddy — wired together. Closest thing to a production deployment you can self-manage on a single host.
|
||||
|
||||
## One-line installer (recommended)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
|
||||
```
|
||||
|
||||
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
|
||||
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` + `caddy/Caddyfile` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -20,7 +20,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
|
||||
|
||||
| Container | Image | Role |
|
||||
| --------------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `puter-nginx` | `nginx:1.27-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
|
||||
| `puter-caddy` | `caddy:2.11-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
|
||||
| `puter` | `ghcr.io/heyputer/puter` | The app |
|
||||
| `puter-mariadb` | `mariadb:11` | SQL database — schema applied automatically on first boot |
|
||||
| `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter |
|
||||
@@ -47,7 +47,6 @@ State lives under `./puter/data/<service>/`.
|
||||
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
@@ -65,7 +64,7 @@ S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=puter-local
|
||||
EOF
|
||||
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
mkdir -p puter/config puter/data puter/data/caddy puter/tls
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "puter.localhost",
|
||||
@@ -78,9 +77,7 @@ cat > puter/config/config.json <<EOF
|
||||
"private_app_hosting_domain": "app.puter.localhost",
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
@@ -134,21 +131,21 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
|
||||
|
||||
Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `jwt_secret_v2` — the HMAC secret Puter signs and verifies auth tokens with (`kid: 'v2'` JWT header). The pre-v2 token format is retired: a token signed with the old `jwt_secret` no longer verifies, and holders are asked to sign in again. If you are upgrading from a release that had `jwt_secret`, drop it from your config — it is ignored.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
|
||||
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
|
||||
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. nginx routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
|
||||
- `trust_proxy: 1` — nginx terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the nginx container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (nginx). Bump to `2` if you put Cloudflare in front of nginx; never set `true` (it trusts every hop and makes XFF forgeable).
|
||||
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
|
||||
- `trust_proxy: 1` — Caddy terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the Caddy container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (Caddy). Bump to `2` if you put Cloudflare in front of Caddy; never set `true` (it trusts every hop and makes XFF forgeable).
|
||||
|
||||
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
|
||||
|
||||
## Step 2 — Point DNS at the server \[Optional\]
|
||||
|
||||
In your DNS provider, add records for the main domain plus the subdomains Puter and nginx route on (`api.*`, `site.*`, `app.*`, `s3.*`):
|
||||
In your DNS provider, add records for the main domain plus the subdomains Puter and Caddy route on (`api.*`, `site.*`, `app.*`, `s3.*`):
|
||||
|
||||
```
|
||||
A puter.localhost → <your server's public IP>
|
||||
@@ -163,7 +160,7 @@ A dev.puter.localhost → <your server's public IP>
|
||||
A *.dev.puter.localhost → <your server's public IP>
|
||||
```
|
||||
|
||||
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and nginx routes browser S3 traffic via `s3.*` to RustFS.
|
||||
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and Caddy routes browser S3 traffic via `s3.*` to RustFS.
|
||||
|
||||
## Step 3 — TLS (recommended for public installs) \[Optional\]
|
||||
|
||||
@@ -182,13 +179,15 @@ sudo certbot certonly --manual --preferred-challenges dns \
|
||||
|
||||
The cert needs to cover `*.puter.localhost` so that `s3.puter.localhost` (browser S3 endpoint), plus Puter's own `api.*` / `app.*` subdomains, all validate.
|
||||
|
||||
> **Why not Caddy's automatic HTTPS?** Caddy issues certs over ACME by itself, but only for hostnames it knows up front, and only over HTTP-01 with the stock image. Puter serves every user's site and app on a subdomain it invents at runtime (`<name>.site.<domain>`, `<name>.app.<domain>`), which needs a **wildcard** cert — and wildcards require DNS-01, which requires a DNS-provider plugin that isn't in `caddy:2.11-alpine`. So `caddy/Caddyfile` sets `auto_https off` and reads the cert you supply below. If you'd rather have Caddy manage certs, build an image with the plugin for your DNS provider (`xcaddy build --with github.com/caddy-dns/<provider>`) and swap the `tls` line for a `tls { dns <provider> … }` block.
|
||||
|
||||
Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
|
||||
**Wire nginx to use them:**
|
||||
**Wire Caddy to use them:**
|
||||
|
||||
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both** `# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
|
||||
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
|
||||
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
|
||||
1. Open [caddy/Caddyfile](../caddy/Caddyfile) and uncomment the `# :443 { … }` block at the bottom.
|
||||
2. (Optional but recommended) Replace the plain `:80 { import puter_routes }` block with the `redir` version shown alongside it, to force HTTPS everywhere.
|
||||
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `caddy` service.
|
||||
4. In `.env`, uncomment `HTTPS_PORT=443`.
|
||||
5. In `config.json`, switch:
|
||||
```json
|
||||
@@ -199,8 +198,36 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Running behind your own reverse proxy
|
||||
|
||||
The bundled `puter-caddy` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Traefik, nginx, HAProxy, a cloud load balancer, another Caddy). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
|
||||
|
||||
You can keep the bundled Caddy as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
|
||||
|
||||
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
|
||||
|
||||
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
|
||||
|
||||
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
|
||||
|
||||
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
|
||||
|
||||
4. **Forward the standard proxy headers.** Puter needs:
|
||||
- `Host` — the original request host (rule 1).
|
||||
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
|
||||
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
|
||||
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
|
||||
|
||||
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled Caddy if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
|
||||
|
||||
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
|
||||
|
||||
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
Both [docker-compose.yml](../docker-compose.yml) and [caddy/Caddyfile](../caddy/Caddyfile) need to sit next to your `.env` — clone the repo, or copy those two paths out of it. The Caddyfile is bind-mounted read-only; if it's missing, Docker creates a *directory* at that path and `puter-caddy` dies with "not a directory".
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -343,6 +370,37 @@ Default is 100 MB per user.
|
||||
|
||||
Set `is_storage_limited: false` for unlimited (bounded by host disk).
|
||||
|
||||
### Usage metering and budgets
|
||||
|
||||
Puter meters what an account costs to serve — bytes sent back, object-store
|
||||
requests, KV capacity, AI tokens — against a monthly budget, and refuses the
|
||||
operations that spend it once that budget is gone. The refusal is a `402` with
|
||||
code `insufficient_funds`; reads that only describe things, and every kind of
|
||||
deletion, stay available so an account can always see what it has and clear it.
|
||||
|
||||
On a self-hosted install this is almost certainly not what you want. There is
|
||||
nowhere to buy more, so accounts are held to the free monthly allowance
|
||||
(US$0.25 of measured cost — roughly 2 GiB of downloads) and start being refused
|
||||
after that. Turn it off:
|
||||
|
||||
```json
|
||||
"unlimitedMetering": true
|
||||
```
|
||||
|
||||
Every account then resolves to an unlimited policy. Usage is still recorded, so
|
||||
the dashboard still shows what is being consumed; nothing is ever refused for
|
||||
lack of budget.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
"meteringEnforcement": { "enabled": false }
|
||||
```
|
||||
|
||||
Calls driven by a deployed worker are exempt from enforcement by default,
|
||||
because a worker has no prompt to show and nobody watching it fail. Set
|
||||
`"meteringEnforcement": { "workers": true }` to include them.
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
@@ -353,6 +411,15 @@ Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Disable new signups
|
||||
|
||||
Force visitors to log in with an existing account instead of creating a
|
||||
temporary or permanent one.
|
||||
|
||||
```json
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
@@ -434,7 +501,7 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Site loads but I get "Bad Gateway" / nginx errors.**
|
||||
**Site loads but I get a 502 / "Bad Gateway" from Caddy.**
|
||||
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
|
||||
|
||||
**Login screen says "admin password not set".**
|
||||
|
||||
+23
-13
@@ -2,7 +2,7 @@
|
||||
# Self-hosted Puter — full stack.
|
||||
#
|
||||
# Brings up Puter + every external service it needs:
|
||||
# - nginx : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
|
||||
# - caddy : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
|
||||
# - valkey : redis-compatible cache / rate-limiter backend
|
||||
# - mariadb : SQL database (Puter applies its schema on first boot)
|
||||
# - dynamo : DynamoDB-local (KV store; Puter creates the table itself)
|
||||
@@ -23,7 +23,8 @@
|
||||
#
|
||||
# Production:
|
||||
# - Always replace the default passwords / S3 keys / Puter secrets.
|
||||
# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
|
||||
# - Enable TLS on the bundled Caddy (see caddy/Caddyfile) or front the
|
||||
# stack with your own TLS-terminating proxy.
|
||||
# - Move state-bearing volumes to a backed-up location.
|
||||
|
||||
services:
|
||||
@@ -118,7 +119,7 @@ services:
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data:z
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# Internal-only — browsers reach RustFS via Caddy (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
# 9000 directly on the host for `aws-cli` / debugging.
|
||||
@@ -249,7 +250,7 @@ services:
|
||||
condition: service_started
|
||||
s3-init:
|
||||
condition: service_completed_successfully
|
||||
# Internal-only: nginx reaches it on the compose network. Uncomment
|
||||
# Internal-only: Caddy reaches it on the compose network. Uncomment
|
||||
# to also expose port 4100 directly on the host (useful for debugging).
|
||||
# ports:
|
||||
# - "4100:4100"
|
||||
@@ -264,29 +265,38 @@ services:
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: puter-nginx
|
||||
caddy:
|
||||
image: caddy:2.11-alpine
|
||||
container_name: puter-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
puter:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# Uncomment when you enable TLS in caddy/Caddyfile:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro,z
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro,z
|
||||
# TLS certs (wildcard fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/caddy/tls:ro,z
|
||||
# Caddy's own state — the cert store it would use if you swap this
|
||||
# config over to ACME. Kept under ./puter/data with everything else
|
||||
# so one directory is the whole backup.
|
||||
- ./puter/data/caddy:/data:z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
# Hits Caddy's local-only admin API rather than proxying through to
|
||||
# Puter: this reports whether the proxy itself is up and configured,
|
||||
# and keeps working once the `:80` block becomes an HTTPS redirect.
|
||||
# 127.0.0.1, not localhost — the admin endpoint is IPv4-only and
|
||||
# busybox wget tries ::1 first.
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://127.0.0.1:2019/config/ >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
+146
-142
@@ -1,75 +1,92 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleAppTelemetryUserCount,
|
||||
handleAppTelemetryUsers,
|
||||
} from './appTelemetry.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
|
||||
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = async (
|
||||
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
|
||||
fn: () => Promise<void>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
// Importing the module registers the `appTelemetry` driver into the shared
|
||||
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
|
||||
import { AppTelemetryDriver } from './appTelemetry.ts';
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: AppTelemetryDriver;
|
||||
|
||||
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
|
||||
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = <T>(
|
||||
actor: { user: { uuid: string; id?: number } } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
|
||||
.appTelemetry;
|
||||
// Guard: the driver must have been wired onto the server. If this is
|
||||
// undefined the extension didn't register, and every test below would
|
||||
// fail with a confusing "cannot read property of undefined".
|
||||
expect(driver).toBeInstanceOf(AppTelemetryDriver);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
const seedOwnedApp = async (prefix: string) => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `${prefix}_${slug}`,
|
||||
title: `${prefix} ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
return { owner, app: app! };
|
||||
};
|
||||
|
||||
const seedUser = async (prefix: string, email: string | null = null) =>
|
||||
server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email,
|
||||
});
|
||||
|
||||
const grantAuthenticated = async (appId: number, userId: number) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, 'flag:app-is-authenticated', null],
|
||||
);
|
||||
};
|
||||
|
||||
const grantEmailRead = async (
|
||||
appId: number,
|
||||
userId: number,
|
||||
userUuid: string,
|
||||
) => {
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||||
[userId, appId, `user:${userUuid}:email:read`, null],
|
||||
);
|
||||
};
|
||||
|
||||
describe('appTelemetry driver — get_users', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(makeReq({}), res),
|
||||
).rejects.toMatchObject({
|
||||
await expect(driver.get_users({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('app_uuid'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) for a non-numeric limit', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('limit'),
|
||||
@@ -77,22 +94,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when the app cannot be found', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-does-not-exist' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-does-not-exist' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
@@ -100,25 +109,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(403) when the caller does not own the app', async () => {
|
||||
// Seed an owner user + an app belonging to them, then call the
|
||||
// handler as a *different* user. The real permission service should
|
||||
// reject since the caller has no `apps-of-user:<owner>:write` perm.
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app_${slug}`,
|
||||
title: `App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
// Seed an owner + their app, then call as a *different* user. The
|
||||
// real permission service should reject: the caller has no
|
||||
// `apps-of-user:<owner>:write`.
|
||||
const { app } = await seedOwnedApp('owner');
|
||||
const intruder = await server.stores.user.create({
|
||||
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
@@ -126,15 +120,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
email: null,
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await callWithActor(
|
||||
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
|
||||
async () => {
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: app.uid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Permission denied',
|
||||
@@ -144,78 +134,92 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('returns an empty list for an owned app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app2_${slug}`,
|
||||
title: `App2 ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
const { owner, app } = await seedOwnedApp('owner2');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callWithActor(
|
||||
const result = await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
async () => {
|
||||
await handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
},
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits user_email for a user who did not grant email:read', async () => {
|
||||
const { owner, app } = await seedOwnedApp('noemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user).toBe(member.username);
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
// No grant → the field must be absent (not just null), so the email
|
||||
// never leaks to the app owner.
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns user_email when the user granted email:read to the app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('withemail');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
await grantEmailRead(
|
||||
app.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(row.user_uuid).toBe(member.uuid);
|
||||
expect(row.user_email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not leak email granted to a *different* app', async () => {
|
||||
const { owner, app } = await seedOwnedApp('appA');
|
||||
const { app: otherApp } = await seedOwnedApp('appB');
|
||||
const member = await seedUser('member', '[email protected]');
|
||||
await grantAuthenticated(app.id as number, member.id as number);
|
||||
// Grant email:read against the OTHER app only.
|
||||
await grantEmailRead(
|
||||
otherApp.id as number,
|
||||
member.id as number,
|
||||
member.uuid,
|
||||
);
|
||||
|
||||
const [row] = (await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(makeReq({}), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(driver.user_count({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(404) for an unknown app_uuid', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: 'app-not-here' }),
|
||||
res,
|
||||
),
|
||||
driver.user_count({ app_uuid: 'app-not-here' }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('returns { count: 0 } for an app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `appcount_${slug}`,
|
||||
title: `AppCount ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual({ count: 0 });
|
||||
it('returns 0 for an app with no authenticated users', async () => {
|
||||
const { app } = await seedOwnedApp('appcount');
|
||||
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
+152
-80
@@ -1,11 +1,24 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
||||
import type {
|
||||
DriverConcurrentConfig,
|
||||
DriverRateLimitConfig,
|
||||
} from '@heyputer/backend/src/drivers/meta';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '@heyputer/backend/src/services/metering/consts';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
// App-telemetry lets an app owner enumerate the users who have
|
||||
// authenticated into their app. v1 shipped this as a driver on the
|
||||
// `app-telemetry` interface (methods `get_users` / `user_count`) and
|
||||
// puter-js's `puter.apps(...).getUsers()` still calls it that way
|
||||
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
|
||||
// This is the v2 port of that driver — same interface/method/return shapes
|
||||
// so existing puter-js callers work unchanged.
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
@@ -41,91 +54,150 @@ const parseIntParam = (
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const handleAppTelemetryUsers = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
/**
|
||||
* Driver exposing the `app-telemetry` interface.
|
||||
*
|
||||
* The `/drivers/call` permission gate checks
|
||||
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds via
|
||||
* the blanket `service` grant (hardcoded-permissions.js +
|
||||
* `default_implicit_user_app_permissions`). The real authorization — "is the
|
||||
* caller the app owner?" — is enforced inside `get_users` below, exactly as v1
|
||||
* did.
|
||||
*/
|
||||
export class AppTelemetryDriver extends PuterDriver {
|
||||
readonly driverInterface = 'app-telemetry';
|
||||
readonly driverName = 'app-telemetry';
|
||||
readonly isDefault = true;
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
// Declaring nothing here would leave both methods on the generic
|
||||
// 600/minute driver default, which does not fit a paginated scan that
|
||||
// can ask for MAX_LIMIT rows at MAX_OFFSET. This is a dashboard read —
|
||||
// nobody calls it in a loop.
|
||||
readonly rateLimit: DriverRateLimitConfig = {
|
||||
default: {
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 30,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
readonly concurrent: DriverConcurrentConfig = {
|
||||
default: {
|
||||
limit: 5,
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 2,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
/** Users who have authenticated into the given app (owner-only). */
|
||||
async get_users({
|
||||
app_uuid,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<
|
||||
Array<{ user: string; user_uuid: string; user_email?: string | null }>
|
||||
> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
const safeLimit = parseIntParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
};
|
||||
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly — the raw
|
||||
// app row only carries `owner_user_id`. (v1 got the owner for free
|
||||
// because its entity-storage layer eager-joined the owner row.)
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
export const handleAppTelemetryUserCount = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) throw new HttpError(401, 'Authentication required');
|
||||
const ownsApp = await this.services.permission
|
||||
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
const appId = (app as { id: number }).id;
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
)) as Array<{ n: number }>;
|
||||
const users = (await this.clients.db.read(
|
||||
`SELECT u.id, u.username, u.uuid, u.email FROM user_to_app_permissions p
|
||||
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[appId, safeLimit, safeOffset],
|
||||
)) as Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
uuid: string;
|
||||
email: string | null;
|
||||
}>;
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
};
|
||||
// Only surface a user's email if *that user* granted this app the
|
||||
// `user:<their-uuid>:email:read` permission — the same grant
|
||||
// `puter.perms.requestEmail()` obtains and `whoami` honours. This is a
|
||||
// per-user check keyed on the app (not the calling owner-actor): a
|
||||
// user may have authenticated into the app without sharing their
|
||||
// email. Resolve the whole page in one query.
|
||||
const emailPermitted = new Set<number>();
|
||||
if (users.length > 0) {
|
||||
const permStrings = users.map((u) => `user:${u.uuid}:email:read`);
|
||||
const placeholders = permStrings.map(() => '?').join(', ');
|
||||
const grants = (await this.clients.db.read(
|
||||
`SELECT user_id FROM user_to_app_permissions
|
||||
WHERE app_id = ? AND permission IN (${placeholders})`,
|
||||
[appId, ...permStrings],
|
||||
)) as Array<{ user_id: number }>;
|
||||
for (const g of grants) emailPermitted.add(g.user_id);
|
||||
}
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUsers,
|
||||
);
|
||||
return users.map((e) =>
|
||||
emailPermitted.has(e.id)
|
||||
? { user: e.username, user_uuid: e.uuid, user_email: e.email }
|
||||
: { user: e.username, user_uuid: e.uuid },
|
||||
);
|
||||
}
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUserCount,
|
||||
);
|
||||
/** Count of users who have authenticated into the given app. */
|
||||
async user_count({
|
||||
app_uuid,
|
||||
}: { app_uuid?: string } = {}): Promise<number> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const [row] = (await this.clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as { id: number }).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
extension.registerDriver('appTelemetry', AppTelemetryDriver);
|
||||
@@ -0,0 +1,555 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { extensionStore } from '../src/backend/extensions.ts';
|
||||
import type { IConfig } from '../src/backend/types';
|
||||
import './devWatcher.ts';
|
||||
|
||||
type Lifecycle = {
|
||||
onServerStart: () => Promise<void>;
|
||||
onServerShutdown: () => Promise<void>;
|
||||
};
|
||||
|
||||
// The extension registers itself on import; that registry entry is the
|
||||
// only handle on the service class.
|
||||
const DevWatcherService = extensionStore.services.devWatcher as unknown as new (
|
||||
config: IConfig,
|
||||
clients: unknown,
|
||||
stores: unknown,
|
||||
services: unknown,
|
||||
) => Lifecycle;
|
||||
|
||||
const makeService = (config: Record<string, unknown>): Lifecycle =>
|
||||
new DevWatcherService(config as unknown as IConfig, {}, {}, {});
|
||||
|
||||
let workdir: string;
|
||||
|
||||
const write = (relative: string, contents: string): string => {
|
||||
const abs = path.join(workdir, relative);
|
||||
writeFileSync(abs, contents);
|
||||
return abs;
|
||||
};
|
||||
|
||||
/** Poll until `predicate` holds or the budget runs out. */
|
||||
const waitFor = async (
|
||||
predicate: () => boolean,
|
||||
label: string,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
};
|
||||
|
||||
const logLines = (spy: { mock: { calls: unknown[][] } }): string[] =>
|
||||
spy.mock.calls.map((call) => call.map(String).join(' '));
|
||||
|
||||
describe('devWatcher extension', () => {
|
||||
let log: ReturnType<typeof vi.spyOn>;
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
let error: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(() => {
|
||||
workdir = mkdtempSync(path.join(tmpdir(), 'devwatch-test-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
delete (extensionStore.services as Record<string, unknown>).devWatcher;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
log.mockRestore();
|
||||
warn.mockRestore();
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
const started = () =>
|
||||
logLines(log).some((line) =>
|
||||
line.startsWith('[devwatch] starting watchers from'),
|
||||
);
|
||||
|
||||
// -- start gating -------------------------------------------------
|
||||
|
||||
it('does nothing when the server is not running in dev', async () => {
|
||||
await makeService({ env: 'production' }).onServerStart();
|
||||
expect(started()).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when devwatch is explicitly disabled', async () => {
|
||||
await makeService({
|
||||
env: 'dev',
|
||||
devwatch: { enabled: false },
|
||||
}).onServerStart();
|
||||
expect(started()).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when the server opts out with no_devwatch', async () => {
|
||||
await makeService({
|
||||
env: 'dev',
|
||||
no_devwatch: true,
|
||||
devwatch: { enabled: true },
|
||||
}).onServerStart();
|
||||
expect(started()).toBe(false);
|
||||
});
|
||||
|
||||
it('starts in dev even with no explicit devwatch block', async () => {
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
expect(started()).toBe(true);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('starts outside dev when devwatch is explicitly enabled', async () => {
|
||||
const service = makeService({
|
||||
env: 'production',
|
||||
devwatch: {
|
||||
enabled: true,
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
expect(started()).toBe(true);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('ignores a devwatch config that is not an object', async () => {
|
||||
// Falls back to `{}`, so the dev-env rule decides — and the
|
||||
// default webpack entries would be used, so pin it off instead.
|
||||
await makeService({
|
||||
env: 'production',
|
||||
devwatch: 'yes',
|
||||
}).onServerStart();
|
||||
expect(started()).toBe(false);
|
||||
});
|
||||
|
||||
it('only starts once even if the lifecycle hook fires again', async () => {
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
await service.onServerStart();
|
||||
expect(
|
||||
logLines(log).filter((line) =>
|
||||
line.startsWith('[devwatch] starting watchers from'),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('waits out the configured ready delay before resolving', async () => {
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [],
|
||||
ready_delay_ms: 60,
|
||||
},
|
||||
});
|
||||
const start = Date.now();
|
||||
await service.onServerStart();
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(50);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
// -- child commands -----------------------------------------------
|
||||
|
||||
it('spawns a command, line-buffers its output and flushes the tail', async () => {
|
||||
write(
|
||||
'chatty.js',
|
||||
[
|
||||
"process.stdout.write('one\\ntwo\\n');",
|
||||
"process.stderr.write('bad thing\\n');",
|
||||
// No trailing newline: only the stream `end` flushes it.
|
||||
"process.stdout.write('tail-no-newline');",
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [
|
||||
{
|
||||
name: 'chatty',
|
||||
directory: '.',
|
||||
command: 'node',
|
||||
args: ['chatty.js'],
|
||||
},
|
||||
],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
logLines(log).some((l) =>
|
||||
l.includes('[devwatch:chatty:1] tail-no-newline'),
|
||||
),
|
||||
'child stdout',
|
||||
);
|
||||
|
||||
const lines = logLines(log);
|
||||
expect(lines).toContain('[devwatch:chatty:1] one');
|
||||
expect(lines).toContain('[devwatch:chatty:1] two');
|
||||
expect(logLines(warn)).toContain('[devwatch:chatty:2] bad thing');
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
logLines(log).some((l) =>
|
||||
l.includes('[devwatch:chatty:exit] process exited'),
|
||||
),
|
||||
'child exit',
|
||||
);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('passes literal and computed env values to the child', async () => {
|
||||
write(
|
||||
'env.js',
|
||||
'process.stdout.write(`STATIC=${process.env.STATIC_VALUE} ORIGIN=${process.env.FROM_CONFIG} MISSING=${process.env.BLOWS_UP}\\n`);',
|
||||
);
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
origin: 'http://puter.localhost:4100',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [
|
||||
{
|
||||
name: 'envtest',
|
||||
directory: '.',
|
||||
command: 'node',
|
||||
args: ['env.js'],
|
||||
env: {
|
||||
STATIC_VALUE: 'literal',
|
||||
FROM_CONFIG: ({
|
||||
global_config,
|
||||
}: {
|
||||
global_config: Record<string, unknown> | null;
|
||||
}) => String(global_config?.origin ?? ''),
|
||||
// Reading through a null is the "config not
|
||||
// loaded yet" shape the extension deliberately
|
||||
// stays quiet about.
|
||||
BLOWS_UP: () => {
|
||||
const nothing = null as unknown as {
|
||||
x: string;
|
||||
};
|
||||
return nothing.x;
|
||||
},
|
||||
NOISY: () => {
|
||||
throw new Error('unexpected failure');
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
|
||||
await waitFor(
|
||||
() => logLines(log).some((l) => l.includes('STATIC=literal')),
|
||||
'env output',
|
||||
);
|
||||
const line = logLines(log).find((l) => l.includes('STATIC=literal'))!;
|
||||
expect(line).toContain('ORIGIN=http://puter.localhost:4100');
|
||||
expect(line).toContain('MISSING=undefined');
|
||||
|
||||
// A null-property read is expected noise and stays silent; any
|
||||
// other failure is reported.
|
||||
const warnings = logLines(warn);
|
||||
expect(
|
||||
warnings.some((w) => w.includes('could not evaluate env function')),
|
||||
).toBe(true);
|
||||
expect(warnings.some((w) => w.includes('for BLOWS_UP'))).toBe(false);
|
||||
expect(warnings.some((w) => w.includes('for NOISY'))).toBe(true);
|
||||
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('kills a still-running child on shutdown', async () => {
|
||||
write('forever.js', 'setInterval(() => {}, 1000);');
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [
|
||||
{
|
||||
name: 'forever',
|
||||
directory: '.',
|
||||
command: 'node',
|
||||
args: ['forever.js'],
|
||||
},
|
||||
],
|
||||
webpack: [],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
await service.onServerStart();
|
||||
await service.onServerShutdown();
|
||||
|
||||
expect(logLines(log)).toContain('[devwatch:forever] stopping process');
|
||||
await waitFor(
|
||||
() =>
|
||||
logLines(log).some((l) =>
|
||||
l.includes('[devwatch:forever:exit] process exited'),
|
||||
),
|
||||
'child killed',
|
||||
);
|
||||
});
|
||||
|
||||
// -- webpack watchers ---------------------------------------------
|
||||
|
||||
const makeWebpackProject = (
|
||||
name: string,
|
||||
configFile: string,
|
||||
contents: string,
|
||||
packageJson?: string,
|
||||
): string => {
|
||||
const dir = path.join(workdir, name);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
writeFileSync(
|
||||
path.join(
|
||||
(() => {
|
||||
const { mkdirSync } =
|
||||
require('node:fs') as typeof import('node:fs');
|
||||
mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
return dir;
|
||||
})(),
|
||||
'src/entry.js',
|
||||
),
|
||||
"console.log('hello');\n",
|
||||
);
|
||||
writeFileSync(path.join(dir, configFile), contents);
|
||||
if (packageJson) {
|
||||
writeFileSync(path.join(dir, 'package.json'), packageJson);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
it('compiles a CommonJS webpack config and reports later rebuilds', async () => {
|
||||
const dir = makeWebpackProject(
|
||||
'cjs-project',
|
||||
'webpack.config.cjs',
|
||||
`module.exports = {
|
||||
mode: 'development',
|
||||
entry: './src/entry.js',
|
||||
output: { path: __dirname + '/out', filename: 'bundle.js' },
|
||||
};`,
|
||||
);
|
||||
|
||||
let onConfigSaw: Record<string, unknown> | undefined;
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [
|
||||
{
|
||||
name: 'cjs',
|
||||
directory: dir,
|
||||
onConfig: (cfg: Record<string, unknown>) => {
|
||||
onConfigSaw = cfg;
|
||||
},
|
||||
},
|
||||
],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await service.onServerStart();
|
||||
expect(onConfigSaw).toBeDefined();
|
||||
// The watcher context is anchored at <root>/<directory> so relative
|
||||
// entries resolve regardless of the server's cwd.
|
||||
expect(onConfigSaw!.context).toBe(path.join(workdir, dir));
|
||||
|
||||
// First build is silent by design; force a rebuild and assert the
|
||||
// update line shows up.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
writeFileSync(
|
||||
path.join(workdir, dir, 'src/entry.js'),
|
||||
"console.log('hello again');\n",
|
||||
);
|
||||
await waitFor(
|
||||
() =>
|
||||
logLines(log).some((l) =>
|
||||
l.includes('[devwatch] updated cjs using Webpack'),
|
||||
),
|
||||
'webpack rebuild',
|
||||
);
|
||||
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('resolves an ESM webpack config declared through package.json type', async () => {
|
||||
const dir = makeWebpackProject(
|
||||
'esm-project',
|
||||
'webpack.config.js',
|
||||
`export default {
|
||||
mode: 'development',
|
||||
entry: './src/entry.js',
|
||||
output: { filename: 'bundle.js' },
|
||||
};`,
|
||||
JSON.stringify({ name: 'esm-project', type: 'module' }),
|
||||
);
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [{ directory: dir }],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await service.onServerStart();
|
||||
await service.onServerShutdown();
|
||||
// Falling back to the directory as the display name is the
|
||||
// documented behaviour when `name` is omitted.
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls a config exported as a function and honours an explicit context', async () => {
|
||||
const dir = makeWebpackProject(
|
||||
'fn-project',
|
||||
'webpack.config.cjs',
|
||||
`module.exports = () => ({
|
||||
mode: 'development',
|
||||
context: 'src',
|
||||
entry: './entry.js',
|
||||
name: process.env.WEBPACK_MARKER,
|
||||
output: { path: __dirname + '/out', filename: 'bundle.js' },
|
||||
});`,
|
||||
);
|
||||
|
||||
let seen: Record<string, unknown> | undefined;
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [
|
||||
{
|
||||
name: 'fn',
|
||||
directory: dir,
|
||||
env: { WEBPACK_MARKER: 'set-during-load' },
|
||||
onConfig: (cfg: Record<string, unknown>) => {
|
||||
seen = cfg;
|
||||
},
|
||||
},
|
||||
],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await service.onServerStart();
|
||||
// Relative `context` resolves against <root>/<directory>.
|
||||
expect(seen!.context).toBe(path.join(workdir, dir, 'src'));
|
||||
// The entry's env map is applied while the config module runs, and
|
||||
// restored afterwards.
|
||||
expect(seen!.name).toBe('set-during-load');
|
||||
expect(process.env.WEBPACK_MARKER).toBeUndefined();
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('reports a failing compilation instead of crashing the server', async () => {
|
||||
const dir = makeWebpackProject(
|
||||
'broken-project',
|
||||
'webpack.config.cjs',
|
||||
`module.exports = {
|
||||
mode: 'development',
|
||||
entry: './src/does-not-exist.js',
|
||||
output: { path: __dirname + '/out', filename: 'bundle.js' },
|
||||
};`,
|
||||
);
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [{ name: 'broken', directory: dir }],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await service.onServerStart();
|
||||
await waitFor(
|
||||
() =>
|
||||
logLines(error).some((l) =>
|
||||
l.includes('[devwatch] failed to update broken'),
|
||||
),
|
||||
'webpack failure',
|
||||
);
|
||||
expect(
|
||||
logLines(error).some((l) =>
|
||||
l.includes('[devwatch] error information: broken'),
|
||||
),
|
||||
).toBe(true);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
|
||||
it('fails loudly when a directory has no webpack config at all', async () => {
|
||||
const dir = path.join('no-config');
|
||||
const { mkdirSync } = await import('node:fs');
|
||||
mkdirSync(path.join(workdir, dir), { recursive: true });
|
||||
|
||||
const service = makeService({
|
||||
env: 'dev',
|
||||
devwatch: {
|
||||
root: workdir,
|
||||
commands: [],
|
||||
webpack: [{ name: 'missing', directory: dir }],
|
||||
ready_delay_ms: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.onServerStart()).rejects.toThrow(
|
||||
'could not find webpack config for: no-config',
|
||||
);
|
||||
await service.onServerShutdown();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
// index_url is required so the dashboard can derive a hostname title
|
||||
// for anonymous (app-…) apps.
|
||||
expect(list[0].index_url).toBe(app!.index_url);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
// An owned app is not external, and the raw owner id must not leak.
|
||||
expect(list[0].external).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||||
const app = await server.stores.app.createFromOrigin(
|
||||
`app-${slug}`,
|
||||
`https://external-${slug}.example.com`,
|
||||
);
|
||||
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).toHaveLength(1);
|
||||
expect(list[0].external).toBe(true);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
apps.index_url,
|
||||
apps.owner_user_id,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
@@ -62,15 +64,33 @@ export const handleInstalledApps = async (
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
installedApps.map((app) => {
|
||||
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
||||
// user — it's an "external" app. Derive a flag and don't leak the
|
||||
// raw owner id to the client.
|
||||
const { owner_user_id, ...rest } = app;
|
||||
const external = owner_user_id == null || owner_user_id === '';
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/installedApps',
|
||||
{ subdomain: 'api', requireUserActor: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
scope: 'installed-apps',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
handleInstalledApps,
|
||||
);
|
||||
@@ -48,7 +48,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
|
||||
+30
-6
@@ -1,11 +1,12 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import {
|
||||
controllersContainers,
|
||||
driversContainers,
|
||||
servicesContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
const clients = extension.import('client');
|
||||
@@ -18,7 +19,7 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
const all: Record<string, unknown>[] = [];
|
||||
const collect = (
|
||||
source: Record<string, unknown>,
|
||||
kind: 'driver' | 'controller',
|
||||
kind: 'driver' | 'controller' | 'service',
|
||||
) => {
|
||||
for (const [name, instance] of Object.entries(source)) {
|
||||
const fn = (
|
||||
@@ -43,6 +44,9 @@ function collectAllCosts(): Record<string, unknown>[] {
|
||||
};
|
||||
collect(driversContainers as Record<string, unknown>, 'driver');
|
||||
collect(controllersContainers as Record<string, unknown>, 'controller');
|
||||
// Services report the costs that aren't tied to one endpoint — egress,
|
||||
// which is metered for every response there is.
|
||||
collect(servicesContainers as Record<string, unknown>, 'service');
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -110,26 +114,46 @@ export const handleMeteringAllCosts = async (
|
||||
res.json({ costs: cachedAllCosts });
|
||||
};
|
||||
|
||||
/** Dashboard reads over the per-actor KV aggregates. */
|
||||
const USAGE_READ_LIMIT = {
|
||||
scope: 'metering-usage',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user' as const,
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/metering/usage',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringUsage,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/usage/:appIdOrName',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringUsageForApp,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/globalUsage',
|
||||
{ subdomain: 'api', adminOnly: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
adminOnly: true,
|
||||
// Sums across every shard of the global aggregate. Admin-gated, so
|
||||
// this is loop protection — but one accidental poll is an
|
||||
// expensive minute.
|
||||
rateLimit: {
|
||||
scope: 'metering-global-usage',
|
||||
limit: 10,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
handleMeteringGlobalUsage,
|
||||
);
|
||||
|
||||
extension.get(
|
||||
'/metering/allCosts',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
{ subdomain: 'api', requireAuth: true, rateLimit: USAGE_READ_LIMIT },
|
||||
handleMeteringAllCosts,
|
||||
);
|
||||
@@ -71,9 +71,7 @@ describe('serverInfo extension — handleServerInfo', () => {
|
||||
const statfsSpy = vi
|
||||
.spyOn(fs.default, 'statfs')
|
||||
.mockRejectedValue(new Error('statfs unsupported'));
|
||||
const errSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleServerInfo({} as Request, res);
|
||||
@@ -88,4 +86,18 @@ describe('serverInfo extension — handleServerInfo', () => {
|
||||
statfsSpy.mockRestore();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reports an unknown CPU model when os.cpus() comes back empty', async () => {
|
||||
const os = await import('node:os');
|
||||
const cpusSpy = vi.spyOn(os.default, 'cpus').mockReturnValue([]);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleServerInfo({} as Request, res);
|
||||
|
||||
const cpu = captured.body!.cpu as Record<string, unknown>;
|
||||
expect(cpu.model).toBe('Unknown');
|
||||
expect(cpu.cores).toBe(0);
|
||||
|
||||
cpusSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleFsCopyNodeThumbnail,
|
||||
handleFsRemoveNodeThumbnail,
|
||||
handleThumbnailCreated,
|
||||
handleThumbnailRead,
|
||||
@@ -26,6 +28,9 @@ const TINY_PNG_BASE64 =
|
||||
|
||||
const BUCKET = 'puter-local';
|
||||
|
||||
// Keys the extension will accept back: its own `thumbnails/<uuid>` namespace.
|
||||
const mintedKey = () => `thumbnails/${crypto.randomUUID()}`;
|
||||
|
||||
const streamToBuffer = async (
|
||||
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
|
||||
): Promise<Buffer> => {
|
||||
@@ -41,7 +46,7 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
|
||||
@@ -108,7 +113,7 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('returns early when event has no items array', async () => {
|
||||
@@ -188,14 +193,14 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
|
||||
// Seed an object so the presigned URL points at something real
|
||||
// (the signer itself doesn't validate existence, but this keeps
|
||||
// the test honest).
|
||||
const key = 'thumb-read-test';
|
||||
const key = mintedKey();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
@@ -220,6 +225,57 @@ describe('thumbnails extension — handleThumbnailRead', () => {
|
||||
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
|
||||
});
|
||||
|
||||
// `fsentries.thumbnail` is writable through the FS API, so a stored
|
||||
// pointer is attacker input. Signing one the extension didn't mint would
|
||||
// hand out a presigned read of an arbitrary object — including another
|
||||
// user's file, whose key is its fsentry uuid in this same bucket.
|
||||
it.each([
|
||||
// Shaped exactly like an fs object key (and like a legacy thumbnail
|
||||
// row) — indistinguishable from a planted pointer, so it fails closed.
|
||||
[
|
||||
"another user's file object",
|
||||
`s3://${BUCKET}/${crypto.randomUUID()}`,
|
||||
],
|
||||
[
|
||||
'a key outside the thumbnails namespace',
|
||||
`s3://${BUCKET}/secrets/dump`,
|
||||
],
|
||||
[
|
||||
'a namespace-lookalike key',
|
||||
`s3://${BUCKET}/thumbnails/../${crypto.randomUUID()}`,
|
||||
],
|
||||
['a non-uuid inside the namespace', `s3://${BUCKET}/thumbnails/etc`],
|
||||
])('refuses to presign a pointer naming %s', async (_label, thumbnail) => {
|
||||
const entry: Record<string, unknown> = { thumbnail };
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
expect(entry.thumbnail).toBeNull();
|
||||
});
|
||||
|
||||
it('signs against its own bucket, ignoring the one in the pointer', async () => {
|
||||
const key = mintedKey();
|
||||
const entry: Record<string, unknown> = {
|
||||
thumbnail: `s3://attacker-named-bucket/${key}`,
|
||||
};
|
||||
await handleThumbnailRead(entry, {
|
||||
s3,
|
||||
s3Presign,
|
||||
bucketName: BUCKET,
|
||||
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
|
||||
db: stubDb,
|
||||
});
|
||||
// Signed for OUR bucket; `attacker-named-bucket` never reached S3.
|
||||
const signed = entry.thumbnail as string;
|
||||
expect(signed.startsWith('http')).toBe(true);
|
||||
expect(signed).not.toContain('attacker-named-bucket');
|
||||
expect(signed).toContain(BUCKET);
|
||||
});
|
||||
|
||||
it('leaves the thumbnail untouched when not s3/https/data', async () => {
|
||||
const entry: Record<string, unknown> = { thumbnail: 'about:blank' };
|
||||
await handleThumbnailRead(entry, {
|
||||
@@ -281,11 +337,11 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
|
||||
const key = 'thumb-remove-test';
|
||||
const key = mintedKey();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
@@ -297,7 +353,7 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: `s3://${BUCKET}/${key}` } },
|
||||
{ s3 },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
|
||||
// GetObject should now error because the key was deleted.
|
||||
@@ -306,15 +362,147 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// The destructive half of the same confused deputy: the stored pointer
|
||||
// decides which object is deleted, so a key the extension didn't mint
|
||||
// must never reach DeleteObject.
|
||||
it('does not delete an object the pointer names but we did not mint', async () => {
|
||||
const victimKey = crypto.randomUUID(); // shaped like an fs object key
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: victimKey,
|
||||
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: `s3://${BUCKET}/${victimKey}` } },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
|
||||
const survivor = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: victimKey }),
|
||||
);
|
||||
expect(survivor.ContentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('is a no-op when the target has no thumbnail', async () => {
|
||||
// Should not throw or attempt a delete.
|
||||
await handleFsRemoveNodeThumbnail({ target: {} }, { s3 });
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: {} },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when the thumbnail URL is not an s3:// pointer', async () => {
|
||||
await handleFsRemoveNodeThumbnail(
|
||||
{ target: { thumbnail: 'https://cdn.example.com/x.png' } },
|
||||
{ s3 },
|
||||
{ s3, bucketName: BUCKET },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
|
||||
let server: PuterServer;
|
||||
let s3: S3Client;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
s3 = server.clients.s3.get();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('duplicates the shared thumbnail object and repoints the copied row', async () => {
|
||||
const sourceKey = mintedKey();
|
||||
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: sourceKey,
|
||||
Body: body,
|
||||
ContentType: 'image/png',
|
||||
}),
|
||||
);
|
||||
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${sourceKey}`,
|
||||
uuid: copyUuid,
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
);
|
||||
|
||||
// The copied row was repointed at a fresh object...
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [, params] = db.write.mock.calls[0] as [string, [string, string]];
|
||||
const [newPointer, updatedUuid] = params;
|
||||
expect(updatedUuid).toBe(copyUuid);
|
||||
expect(newPointer.startsWith(`s3://${BUCKET}/thumbnails/`)).toBe(true);
|
||||
expect(newPointer).not.toBe(`s3://${BUCKET}/${sourceKey}`);
|
||||
|
||||
// ...whose content matches, while the source object survives — so
|
||||
// deleting either entry can no longer break the other's thumbnail.
|
||||
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
|
||||
const duplicated = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
|
||||
);
|
||||
expect(
|
||||
(await streamToBuffer(duplicated.Body as never)).equals(body),
|
||||
).toBe(true);
|
||||
const original = await s3.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey }),
|
||||
);
|
||||
expect(original.ContentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('drops the pointer when the shared object is already gone', async () => {
|
||||
const copyUuid = crypto.randomUUID();
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${mintedKey()}`, // never uploaded
|
||||
uuid: copyUuid,
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
);
|
||||
|
||||
expect(db.write).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
|
||||
expect(sql).toContain('NULL');
|
||||
expect(params).toEqual([copyUuid]);
|
||||
});
|
||||
|
||||
it('does not duplicate an object the pointer names but we did not mint', async () => {
|
||||
const foreignKey = crypto.randomUUID(); // shaped like an fs object key
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{
|
||||
copy: {
|
||||
thumbnail: `s3://${BUCKET}/${foreignKey}`,
|
||||
uuid: crypto.randomUUID(),
|
||||
},
|
||||
},
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the copy has no thumbnail', async () => {
|
||||
const db = { write: vi.fn().mockResolvedValue(undefined) };
|
||||
await handleFsCopyNodeThumbnail(
|
||||
{ copy: { thumbnail: null, uuid: crypto.randomUUID() } },
|
||||
{ s3, bucketName: BUCKET, db },
|
||||
);
|
||||
expect(db.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+152
-29
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CopyObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
@@ -13,6 +14,54 @@ const clients = extension.import('client');
|
||||
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_THUMBNAIL_PIXELS = 64e6;
|
||||
|
||||
// Namespace every object this extension writes. An fs object's key is its
|
||||
// bare fsentry uuid and the default config points `thumbnailStore.name` at
|
||||
// the same bucket as `s3_bucket`, so without a prefix of our own there is no
|
||||
// way to tell a thumbnail we minted from any other object in the deployment.
|
||||
const THUMBNAIL_KEY_PREFIX = 'thumbnails/';
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const mintThumbnailKey = (): string =>
|
||||
`${THUMBNAIL_KEY_PREFIX}${crypto.randomUUID()}`;
|
||||
|
||||
/**
|
||||
* Extract the object key from a stored thumbnail pointer, or null when the
|
||||
* pointer isn't one this extension minted.
|
||||
*
|
||||
* `fsentries.thumbnail` is writable through the FS API, so neither half of the
|
||||
* stored string is trusted: the bucket is discarded (callers always pass their
|
||||
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} with a random
|
||||
* uuid. Honouring an arbitrary key would lend this extension's storage
|
||||
* credentials to whatever object the caller named — in the shared-bucket layout
|
||||
* that is every user's file, since an fs object's key is its fsentry uuid.
|
||||
* Legacy bare-uuid thumbnails fail the check and are treated as absent; they
|
||||
* are indistinguishable from a planted pointer, so there is nothing safer to do
|
||||
* with them than stop signing them.
|
||||
*/
|
||||
const resolveThumbnailKey = (pointer: string): string | null => {
|
||||
let key: string;
|
||||
if (pointer.startsWith('s3://')) {
|
||||
const rest = pointer.slice('s3://'.length);
|
||||
const slash = rest.indexOf('/');
|
||||
if (slash === -1) return null;
|
||||
key = rest.slice(slash + 1);
|
||||
} else {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(pointer).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const segments = pathname.replace(/^\/+/, '').split('/');
|
||||
segments.shift(); // bucket
|
||||
key = segments.join('/');
|
||||
}
|
||||
if (!key.startsWith(THUMBNAIL_KEY_PREFIX)) return null;
|
||||
const id = key.slice(THUMBNAIL_KEY_PREFIX.length);
|
||||
return UUID_PATTERN.test(id) ? key : null;
|
||||
};
|
||||
|
||||
// S3 client + bucket config — lazily resolved after boot from config.
|
||||
let s3Client: S3Client | null = null;
|
||||
let s3PresignClient: S3Client | null = null;
|
||||
@@ -112,7 +161,7 @@ export async function handleThumbnailCreated(
|
||||
return;
|
||||
}
|
||||
|
||||
const key = crypto.randomUUID();
|
||||
const key = mintThumbnailKey();
|
||||
event.url = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
await deps.s3.send(
|
||||
@@ -151,7 +200,7 @@ export const handleThumbnailUploadPrepare = async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = crypto.randomUUID();
|
||||
const key = mintThumbnailKey();
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
Key: key,
|
||||
@@ -178,27 +227,27 @@ export const handleThumbnailRead = async (
|
||||
if (typeof thumb !== 'string' || !thumb) return;
|
||||
const presignClient = deps.s3Presign;
|
||||
|
||||
if (thumb.startsWith('s3://')) {
|
||||
const [bucket, key] = thumb.slice(5).split('/');
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
);
|
||||
} else if (
|
||||
thumb.startsWith('https') &&
|
||||
thumb.includes(new URL(deps.bucketEndpoint).hostname)
|
||||
) {
|
||||
if (
|
||||
thumb.startsWith('s3://') ||
|
||||
// Legacy format — remove after full migration
|
||||
const [bucket, key] = new URL(thumb).pathname.slice(1).split('/');
|
||||
(thumb.startsWith('https') &&
|
||||
thumb.includes(new URL(deps.bucketEndpoint).hostname))
|
||||
) {
|
||||
const key = resolveThumbnailKey(thumb);
|
||||
if (!key) {
|
||||
// Not a pointer we minted — refuse to sign it rather than hand
|
||||
// out a presigned read of whatever object it names.
|
||||
entry.thumbnail = null;
|
||||
return;
|
||||
}
|
||||
entry.thumbnail = await getSignedUrl(
|
||||
presignClient,
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
{ expiresIn: 604800 },
|
||||
);
|
||||
} else if (thumb.startsWith('data')) {
|
||||
// Inline data-URL migration: upload to S3 and update the DB entry.
|
||||
const key = crypto.randomUUID();
|
||||
const key = mintThumbnailKey();
|
||||
const { mimeType, data } = base64ParseDataUrl(thumb);
|
||||
const newUrl = `s3://${deps.bucketName}/${key}`;
|
||||
|
||||
@@ -232,15 +281,72 @@ export const handleThumbnailRead = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFsRemoveNodeThumbnail = async (
|
||||
payload: { target: Record<string, unknown> },
|
||||
deps: { s3: S3Client },
|
||||
export const handleFsCopyNodeThumbnail = async (
|
||||
payload: { copy?: { thumbnail?: string | null; uuid?: string } | null },
|
||||
deps: {
|
||||
s3: S3Client;
|
||||
bucketName: string;
|
||||
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
},
|
||||
): Promise<void> => {
|
||||
const thumbnailUrl = payload.target.thumbnail as string | undefined;
|
||||
if (!thumbnailUrl || !thumbnailUrl.startsWith('s3://')) return;
|
||||
const copy = payload.copy;
|
||||
const thumbnailUrl = copy?.thumbnail;
|
||||
if (!copy || !copy.uuid || typeof thumbnailUrl !== 'string') return;
|
||||
|
||||
const [bucket, key] = thumbnailUrl.slice(5).split('/');
|
||||
await deps.s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
// Same trust rule as the read and remove paths: only touch objects this
|
||||
// extension minted.
|
||||
const sourceKey = resolveThumbnailKey(thumbnailUrl);
|
||||
if (!sourceKey) return;
|
||||
|
||||
// The copied row points at the SAME S3 object as its source, and
|
||||
// fs.remove.node deletes the pointed-to object — so the first removal
|
||||
// among the sharers (an overwrite, a trash purge) would break every
|
||||
// other sharer's thumbnail. Give the copy an object of its own.
|
||||
const newKey = mintThumbnailKey();
|
||||
try {
|
||||
await deps.s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: deps.bucketName,
|
||||
CopySource: `${deps.bucketName}/${sourceKey}`,
|
||||
Key: newKey,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
// The shared object is already gone (e.g. a sharer was removed
|
||||
// before this fix existed) — the pointer is dead either way, so
|
||||
// drop it rather than leave the row advertising a thumbnail it
|
||||
// doesn't have.
|
||||
await deps.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?',
|
||||
[copy.uuid],
|
||||
);
|
||||
console.warn('[thumbnails] failed to duplicate thumbnail on copy', err);
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[`s3://${deps.bucketName}/${newKey}`, copy.uuid],
|
||||
);
|
||||
};
|
||||
|
||||
export const handleFsRemoveNodeThumbnail = async (
|
||||
payload: { target: { thumbnail?: string | null } },
|
||||
deps: { s3: S3Client; bucketName: string },
|
||||
): Promise<void> => {
|
||||
const thumbnailUrl = payload.target.thumbnail;
|
||||
if (!thumbnailUrl) return;
|
||||
|
||||
// Same trust rule as the read path, and load-bearing for the same reason:
|
||||
// the pointer decides which object gets deleted, so a key we didn't mint
|
||||
// would let the owner of one file destroy an object belonging to someone
|
||||
// else just by naming it here.
|
||||
const key = resolveThumbnailKey(thumbnailUrl);
|
||||
if (!key) return;
|
||||
|
||||
await deps.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
|
||||
);
|
||||
};
|
||||
|
||||
extension.on(
|
||||
@@ -279,12 +385,29 @@ extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- fs.copy.node ----------------------------------------------------
|
||||
// A copied entry initially shares its source's thumbnail object; duplicate
|
||||
// it so removing either entry can't break the other's thumbnail.
|
||||
|
||||
extension.on('fs.copy.node', async (_key, payload) => {
|
||||
await handleFsCopyNodeThumbnail(
|
||||
payload as {
|
||||
copy?: { thumbnail?: string | null; uuid?: string } | null;
|
||||
},
|
||||
{
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
db: clients.db,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// -- fs.remove.node --------------------------------------------------
|
||||
// Delete S3 thumbnail when the file is removed.
|
||||
|
||||
extension.on(
|
||||
'fs.remove.node',
|
||||
async (_key, payload: { target: Record<string, unknown> }) => {
|
||||
await handleFsRemoveNodeThumbnail(payload, { s3: getClient() });
|
||||
},
|
||||
);
|
||||
extension.on('fs.remove.node', async (_key, payload) => {
|
||||
await handleFsRemoveNodeThumbnail(payload, {
|
||||
s3: getClient(),
|
||||
bucketName: thumbnailBucketName,
|
||||
});
|
||||
});
|
||||
+132
-2
@@ -51,7 +51,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const seedUser = async () => {
|
||||
@@ -107,6 +107,11 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.email).toBe(user.email);
|
||||
expect(body.is_temp).toBe(false);
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// Account creation time, in unix seconds.
|
||||
expect(typeof body.created_ts).toBe('number');
|
||||
expect(body.created_ts).toBeGreaterThan(0);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
@@ -130,7 +135,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips desktop_bg_* and human_readable_age fields for app actors', async () => {
|
||||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -146,14 +151,139 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Account age, in either form, is not exposed to apps.
|
||||
expect(body.created_ts).toBeUndefined();
|
||||
// Directories are user-only.
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
hasDevAccountAccess: true,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Other metadata keys still reach the user's own client.
|
||||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||||
expect(body.hasDevAccountAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends user metadata to app actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.metadata).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('never exposes phone, card fingerprint or signup identity', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
phone: '+15551234567',
|
||||
card_fingerprint: 'fp_ABC123',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
// Guard against a vacuous assertion below: the columns really do hold
|
||||
// the values we then expect never to see on the wire.
|
||||
const stored = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(stored?.phone).toBe('+15551234567');
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
expect(body.card_fingerprint).toBeUndefined();
|
||||
expect(body.password).toBeUndefined();
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
// The verification flags, which the GUI acts on, still ship.
|
||||
expect(body).toHaveProperty('requires_phone_verification');
|
||||
expect(body).toHaveProperty('requires_card_verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Nested sensitive keys are removed too; siblings survive.
|
||||
expect(metadata.billing).toEqual({ tier: 'pro' });
|
||||
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
|
||||
|
||||
// The scrub works on a copy — server-side state is untouched.
|
||||
const fresh = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
|
||||
expect(
|
||||
(fresh?.metadata?.billing as Record<string, unknown>)
|
||||
?.card_fingerprint,
|
||||
).toBe('fp_NESTED');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+114
-12
@@ -14,6 +14,15 @@ const timeago = (() => {
|
||||
return new TimeAgo('en-US');
|
||||
})();
|
||||
|
||||
// User timestamps come off the DB as SQL datetime strings; the wire format
|
||||
// for all of them is unix seconds. Unparseable values are dropped rather
|
||||
// than sent as NaN.
|
||||
const toUnixSeconds = (value: unknown): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const ms = new Date(value as string | number | Date).getTime();
|
||||
return Number.isNaN(ms) ? undefined : Math.round(ms / 1000);
|
||||
};
|
||||
|
||||
// Allowlist of `config.feature_flags` keys safe to surface via /whoami.
|
||||
// Anything not listed here stays server-side, so internal flags
|
||||
// (payment_bypass, staff_only_*, etc.) cannot leak by accident. Add a
|
||||
@@ -24,6 +33,68 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
// Keys that must never leave the server, whoever put them on the response.
|
||||
// `details` is an explicit pick, but the `whoami.details` event hands
|
||||
// listeners the full UserRow next to the object they may write to, and
|
||||
// `metadata` is a free-form blob — so any of these can arrive on the
|
||||
// response without an edit to the pick above. The scrub runs last, over the
|
||||
// whole payload, and is the one place that decides what "sensitive" means.
|
||||
//
|
||||
// Credentials and single-use tokens, the payment/phone identifiers used for
|
||||
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
|
||||
// card number), the network identity recorded at signup, and internal
|
||||
// anti-abuse bookkeeping. `requires_phone_verification` /
|
||||
// `requires_card_verification` stay: they are the flags the GUI acts on, and
|
||||
// they carry no identifier.
|
||||
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'password',
|
||||
'tmp_password',
|
||||
'pass_recovery_token',
|
||||
'email_confirm_code',
|
||||
'email_confirm_token',
|
||||
'change_email_confirm_token',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
'card_fingerprint',
|
||||
'phone',
|
||||
'clean_email',
|
||||
'signup_ip',
|
||||
'signup_ip_forwarded',
|
||||
'signup_user_agent',
|
||||
'signup_origin',
|
||||
'signup_server',
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
value: unknown,
|
||||
seen: Set<object> = new Set(),
|
||||
depth = 0,
|
||||
): void => {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
delete (value as Record<string, unknown>)[key];
|
||||
continue;
|
||||
}
|
||||
scrubSensitive(
|
||||
(value as Record<string, unknown>)[key],
|
||||
seen,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -65,6 +136,13 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-copied (it is decoded JSON) so the scrub below edits the response
|
||||
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
|
||||
// anything else on the denylist — are removed by scrubSensitive.
|
||||
const metadata = user.metadata
|
||||
? structuredClone(user.metadata)
|
||||
: user.metadata;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
@@ -72,10 +150,16 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
// The phone number itself is deliberately absent: nothing on the
|
||||
// client reads it, and it is PII that would otherwise be handed to
|
||||
// every app actor. Only the verification flag ships.
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
@@ -91,10 +175,11 @@ export const handleWhoami = async (
|
||||
: undefined,
|
||||
otp: !!user.otp_enabled,
|
||||
feature_flags,
|
||||
created_ts: toUnixSeconds(user.timestamp),
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
@@ -134,14 +219,9 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
// Last activity
|
||||
if (user.last_activity_ts) {
|
||||
try {
|
||||
details.last_activity_ts = Math.round(
|
||||
new Date(user.last_activity_ts as string).getTime() / 1000,
|
||||
);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
|
||||
if (lastActivityTs !== undefined) {
|
||||
details.last_activity_ts = lastActivityTs;
|
||||
}
|
||||
|
||||
// Strip sensitive fields for app actors
|
||||
@@ -157,6 +237,9 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
@@ -174,19 +257,38 @@ 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;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
// Last word on what ships, after every listener has had its say.
|
||||
scrubSensitive(details);
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
extension.get(
|
||||
'/whoami',
|
||||
{ subdomain: 'api', requireAuth: true, allowUnconfirmed: true },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
// The GUI polls this, and each call fans out to every `whoami`
|
||||
// event listener — so it costs more than the response suggests.
|
||||
//
|
||||
// It is also the call everything else leans on to find out who it is
|
||||
// talking to, so it rides along with unrelated work rather than
|
||||
// arriving at its own pace: the ceiling has to clear whatever the
|
||||
// busiest session is doing, not what a person clicks.
|
||||
rateLimit: {
|
||||
scope: 'whoami',
|
||||
limit: 1_800,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
},
|
||||
handleWhoami,
|
||||
);
|
||||
+15
-12
@@ -11,7 +11,7 @@
|
||||
# What this does, in order:
|
||||
# 1. Checks that docker (with the compose plugin) exists.
|
||||
# 2. Creates ./puter-selfhosted/ (override with $env:PUTER_DIR).
|
||||
# 3. Downloads docker-compose.yml + nginx.conf from the OSS repo.
|
||||
# 3. Downloads docker-compose.yml + caddy/Caddyfile from the OSS repo.
|
||||
# 4. Generates fresh secrets and writes .env + puter/config/config.json.
|
||||
# 5. Runs `docker compose up -d` and prints how to find the admin password.
|
||||
#
|
||||
@@ -23,7 +23,7 @@
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_PORT HTTP port for Caddy (default: 80)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -77,10 +77,10 @@ if ($LASTEXITCODE -ne 0) {
|
||||
# ── Step 2: install dir ─────────────────────────────────────────────
|
||||
$null = New-Item -ItemType Directory -Force -Path $PuterDir
|
||||
Set-Location $PuterDir
|
||||
$null = New-Item -ItemType Directory -Force -Path 'puter/config', 'puter/data', 'puter/tls'
|
||||
$null = New-Item -ItemType Directory -Force -Path 'puter/config', 'puter/data', 'puter/data/caddy', 'puter/tls'
|
||||
Write-Log "install dir: $((Get-Location).Path)"
|
||||
|
||||
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
|
||||
# ── Step 3: docker-compose.yml + Caddy config ──────────────────────
|
||||
Write-Log "downloading docker-compose.yml from $PuterUrl"
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$PuterUrl/docker-compose.yml" -OutFile 'docker-compose.yml' -UseBasicParsing
|
||||
@@ -88,17 +88,19 @@ try {
|
||||
Die "could not fetch $PuterUrl/docker-compose.yml — $_"
|
||||
}
|
||||
|
||||
Write-Log "downloading nginx/nginx.conf from $PuterUrl"
|
||||
$null = New-Item -ItemType Directory -Force -Path 'nginx'
|
||||
# The Caddyfile is domain-agnostic — it answers on every Host and leaves
|
||||
# the subdomain routing to Puter — so there's nothing to template in.
|
||||
Write-Log "downloading caddy/Caddyfile from $PuterUrl"
|
||||
$null = New-Item -ItemType Directory -Force -Path 'caddy'
|
||||
# If the path was previously auto-created as a directory by a failed
|
||||
# `compose up`, remove it so we can write the file there.
|
||||
if (Test-Path 'nginx/nginx.conf' -PathType Container) {
|
||||
Remove-Item 'nginx/nginx.conf' -Recurse -Force
|
||||
if (Test-Path 'caddy/Caddyfile' -PathType Container) {
|
||||
Remove-Item 'caddy/Caddyfile' -Recurse -Force
|
||||
}
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$PuterUrl/nginx/nginx.conf" -OutFile 'nginx/nginx.conf' -UseBasicParsing
|
||||
Invoke-WebRequest -Uri "$PuterUrl/caddy/Caddyfile" -OutFile 'caddy/Caddyfile' -UseBasicParsing
|
||||
} catch {
|
||||
Die "could not fetch $PuterUrl/nginx/nginx.conf — $_"
|
||||
Die "could not fetch $PuterUrl/caddy/Caddyfile — $_"
|
||||
}
|
||||
|
||||
# ── Step 4: secrets, .env, config.json ──────────────────────────────
|
||||
@@ -121,7 +123,8 @@ if ($writeConfig) {
|
||||
|
||||
$envContent = @"
|
||||
HTTP_PORT=$PuterPort
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS (see doc/selfhosting/full-stack.md)
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS in caddy/Caddyfile
|
||||
# # (see "Step 3 — TLS" in doc/self-hosting.md)
|
||||
|
||||
MARIADB_ROOT_PASSWORD=$mariadbRootPw
|
||||
MARIADB_DATABASE=puter
|
||||
@@ -205,4 +208,4 @@ Write-Log " cd $PuterDir; docker compose logs -f puter"
|
||||
Write-Log ''
|
||||
Write-Log "open http://${PuterDomain}:${PuterPort} once the puter container is healthy."
|
||||
Write-Log 'first-boot admin password is logged once — grab it with:'
|
||||
Write-Log " cd $PuterDir; docker compose logs puter | Select-String password"
|
||||
Write-Log " cd $PuterDir; docker compose logs puter | Select-String password"
|
||||
+51
-21
@@ -7,7 +7,8 @@
|
||||
# What this does, in order:
|
||||
# 1. Checks that docker (with the compose plugin), curl, and openssl exist.
|
||||
# 2. Creates ./puter-selfhosted/ (override with PUTER_DIR=...).
|
||||
# 3. Downloads docker-compose.yml from the OSS repo (raw.githubusercontent.com).
|
||||
# 3. Downloads docker-compose.yml + caddy/Caddyfile from the OSS repo
|
||||
# (raw.githubusercontent.com).
|
||||
# 4. Generates fresh secrets and writes .env + puter/config/config.json.
|
||||
# 5. Runs `docker compose up -d` and prints the first-boot admin password.
|
||||
#
|
||||
@@ -16,11 +17,22 @@
|
||||
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
|
||||
#
|
||||
# Tunable env vars:
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for Caddy (default: 80)
|
||||
# PUTER_PROTOCOL public scheme: http | https (default: http)
|
||||
# Set https once TLS is terminating in front of Puter —
|
||||
# either the bundled Caddy with your certs (see "Step 3 —
|
||||
# TLS" in doc/self-hosting.md) or your own proxy (Traefik,
|
||||
# a cloud LB). Puter then builds https:// origins, S3
|
||||
# URLs, and redirects. Leave it http and the installer
|
||||
# serves plain HTTP on PUTER_PORT.
|
||||
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
|
||||
# 1 = the bundled Caddy (or a single external proxy);
|
||||
# 2 = two hops (e.g. Cloudflare → Caddy → Puter).
|
||||
# PUTER_ENV prod | dev (default: prod)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -28,6 +40,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
|
||||
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
|
||||
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
|
||||
PUTER_PORT="${PUTER_PORT:-80}"
|
||||
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
|
||||
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
|
||||
PUTER_ENV="${PUTER_ENV:-prod}"
|
||||
PUTER_FORCE="${PUTER_FORCE:-0}"
|
||||
|
||||
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
|
||||
@@ -60,25 +75,27 @@ mkdir -p puter/config puter/data puter/tls
|
||||
# bind-mount roots sidesteps the mismatch without guessing each image's
|
||||
# internal UID. (Docker Desktop on macOS/Windows papers over this with
|
||||
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
|
||||
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter puter/data/caddy
|
||||
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter puter/data/caddy
|
||||
log "install dir: $(pwd)"
|
||||
|
||||
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
|
||||
# ── Step 3: docker-compose.yml + Caddy config ──────────────────────
|
||||
log "downloading docker-compose.yml from $PUTER_URL"
|
||||
curl -fsSL "$PUTER_URL/docker-compose.yml" -o docker-compose.yml \
|
||||
|| die "could not fetch $PUTER_URL/docker-compose.yml"
|
||||
|
||||
# nginx is mounted as `./nginx/nginx.conf:/etc/nginx/nginx.conf:ro` — if
|
||||
# Caddy is mounted as `./caddy/Caddyfile:/etc/caddy/Caddyfile:ro,z` — if
|
||||
# the host file is missing, docker silently creates a directory at that
|
||||
# path and the mount fails with "not a directory" at container start.
|
||||
log "downloading nginx/nginx.conf from $PUTER_URL"
|
||||
mkdir -p nginx
|
||||
# The Caddyfile is domain-agnostic — it answers on every Host and leaves
|
||||
# the subdomain routing to Puter — so there's nothing to template in.
|
||||
log "downloading caddy/Caddyfile from $PUTER_URL"
|
||||
mkdir -p caddy
|
||||
# If the path was previously auto-created as a dir by a failed `compose up`,
|
||||
# remove it so curl can write the file.
|
||||
[ -d nginx/nginx.conf ] && rmdir nginx/nginx.conf 2>/dev/null || true
|
||||
curl -fsSL "$PUTER_URL/nginx/nginx.conf" -o nginx/nginx.conf \
|
||||
|| die "could not fetch $PUTER_URL/nginx/nginx.conf"
|
||||
[ -d caddy/Caddyfile ] && rmdir caddy/Caddyfile 2>/dev/null || true
|
||||
curl -fsSL "$PUTER_URL/caddy/Caddyfile" -o caddy/Caddyfile \
|
||||
|| die "could not fetch $PUTER_URL/caddy/Caddyfile"
|
||||
|
||||
# ── Step 4: secrets, .env, config.json ──────────────────────────────
|
||||
write_config=1
|
||||
@@ -101,7 +118,8 @@ if [ "$write_config" = "1" ]; then
|
||||
|
||||
cat > .env <<EOF
|
||||
HTTP_PORT=$PUTER_PORT
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS (see doc/selfhosting/full-stack.md)
|
||||
# HTTPS_PORT=443 # uncomment after enabling TLS in caddy/Caddyfile
|
||||
# # (see "Step 3 — TLS" in doc/self-hosting.md)
|
||||
|
||||
MARIADB_ROOT_PASSWORD=$MARIADB_ROOT_PASSWORD
|
||||
MARIADB_DATABASE=puter
|
||||
@@ -117,9 +135,9 @@ EOF
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "$PUTER_DOMAIN",
|
||||
"protocol": "http",
|
||||
"protocol": "$PUTER_PROTOCOL",
|
||||
"pub_port": $PUTER_PORT,
|
||||
"env": "prod",
|
||||
"env": "$PUTER_ENV",
|
||||
|
||||
"static_hosting_domain": "site.$PUTER_DOMAIN",
|
||||
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
|
||||
@@ -159,7 +177,7 @@ EOF
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
|
||||
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
@@ -173,11 +191,23 @@ EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
"trust_proxy": $PUTER_TRUST_PROXY
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# `https` only works if something in front is actually terminating it. The
|
||||
# bundled Caddy does that from ./puter/tls once you enable its `:443` block
|
||||
# (see "Step 3 — TLS" in doc/self-hosting.md); your own edge proxy is fine
|
||||
# too. Neither in place means Puter hands out https:// URLs for an endpoint
|
||||
# that only speaks HTTP — broken logins and mixed content.
|
||||
if [ "$PUTER_PROTOCOL" = "https" ] && [ ! -f puter/tls/fullchain.pem ]; then
|
||||
warn "PUTER_PROTOCOL=https but ./puter/tls/fullchain.pem is missing."
|
||||
warn "The bundled Caddy is still serving plain HTTP on port $PUTER_PORT."
|
||||
warn "Either drop a wildcard cert into ./puter/tls and uncomment the :443"
|
||||
warn "block in caddy/Caddyfile, or terminate TLS in your own proxy."
|
||||
fi
|
||||
|
||||
# ── Step 5: bring it up ─────────────────────────────────────────────
|
||||
log "docker compose up -d"
|
||||
docker compose up -d
|
||||
@@ -187,6 +217,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
|
||||
log "follow puter logs:"
|
||||
log " cd $PUTER_DIR && docker compose logs -f puter"
|
||||
log ""
|
||||
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "first-boot admin password is logged once — grab it with:"
|
||||
log " cd $PUTER_DIR && docker compose logs puter | grep password"
|
||||
@@ -1,135 +0,0 @@
|
||||
# Reverse proxy in front of Puter — mirrors what the prod ALB does:
|
||||
# accepts every Host header, forwards to the Puter container, and lets
|
||||
# the Puter app handle subdomain-based routing internally (api.*,
|
||||
# site.*, app.*, etc).
|
||||
#
|
||||
# To enable TLS:
|
||||
# 1. Drop your fullchain.pem + privkey.pem into ./puter/tls/.
|
||||
# 2. Uncomment the 443 server{} block below.
|
||||
# 3. Update server_name to your domain (and wildcard subdomains).
|
||||
|
||||
worker_processes auto;
|
||||
events {
|
||||
worker_connections 4096;
|
||||
}
|
||||
|
||||
http {
|
||||
# Required for Puter's WebSocket / socket.io upgrades.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Rough size cap that mirrors prod ALB defaults; tune for your
|
||||
# uploads. Puter chunks large uploads, so 1 GiB per request is plenty.
|
||||
client_max_body_size 1024m;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_buffering off;
|
||||
server_tokens off;
|
||||
|
||||
upstream puter_backend {
|
||||
server puter:4100;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream s3_backend {
|
||||
# RustFS — see `s3` service in docker-compose.full.yml. Browsers
|
||||
# PUT/GET here for presigned-URL uploads / downloads. Routed via
|
||||
# the `s3.<domain>` subdomain so signature verification works
|
||||
# (Host header preserved end-to-end) and so HTTPS stays clean
|
||||
# (no mixed-content from a port-9000 host publish).
|
||||
server s3:9000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# ── HTTP (port 80) ─────────────────────────────────────────────
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ~^s3\.;
|
||||
|
||||
location / {
|
||||
proxy_pass http://s3_backend;
|
||||
proxy_http_version 1.1;
|
||||
# Critical: preserve the original Host so RustFS validates
|
||||
# the request against the same host the URL was signed for.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Note: when you enable TLS, replace this block with a redirect:
|
||||
# return 301 https://$host$request_uri;
|
||||
location / {
|
||||
proxy_pass http://puter_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# ── HTTPS (port 443) — uncomment after dropping certs in ./puter/tls/ ─
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# listen [::]:443 ssl;
|
||||
# http2 on;
|
||||
# server_name ~^s3\.;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_timeout 10m;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://s3_backend;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# server {
|
||||
# listen 443 ssl default_server;
|
||||
# listen [::]:443 ssl default_server;
|
||||
# http2 on;
|
||||
# server_name _;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/tls/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/tls/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_timeout 10m;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://puter_backend;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header X-Forwarded-Host $host;
|
||||
# proxy_set_header X-Forwarded-Port $server_port;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection $connection_upgrade;
|
||||
# }
|
||||
# }
|
||||
}
|
||||
Generated
+6473
-4825
File diff suppressed because it is too large.
Load diff
+35
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "puter.com",
|
||||
"version": "2.5.1",
|
||||
"version": "26.07",
|
||||
"author": "Puter Technologies Inc.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"description": "Desktop environment in the browser!",
|
||||
@@ -10,6 +10,7 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@stylistic/eslint-plugin": "^5.3.1",
|
||||
@@ -20,6 +21,8 @@
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"@vitest/coverage-v8": "^4.0.14",
|
||||
"@vitest/ui": "^4.0.14",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-istanbul": "^8.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"clean-css": "^5.3.2",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -31,9 +34,13 @@
|
||||
"globals": "^15.15.0",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"license-check-and-add": "^4.0.5",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-jsdoc": "^1.8.1",
|
||||
"simple-git": "^3.32.3",
|
||||
"typescript": "^5.4.5",
|
||||
"uglify-js": "^3.17.4",
|
||||
@@ -44,18 +51,26 @@
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run build:ts",
|
||||
"test:backend": "npm run setupExtensions && npm run build:workerLib && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && npm run build:workerLib && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:puterjs": "npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
|
||||
"test:puterjs:node": "npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
|
||||
"test:puterjs:browser": "npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
|
||||
"test:puterjs:workerd": "npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
|
||||
"test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
|
||||
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node ./tools/start.mjs",
|
||||
"dev": "npm start",
|
||||
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
"prepare": "husky",
|
||||
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
|
||||
"postinstall": "node ./tools/extensionSetup.mjs"
|
||||
"check:puterjs:types": "node tools/checkPuterjsTypes.mjs",
|
||||
"typecheck": "node tools/typecheck.mjs",
|
||||
"typecheck:update": "node tools/typecheck.mjs --update",
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/*",
|
||||
@@ -77,9 +92,21 @@
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@google/genai": false,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AlarmClient } from './AlarmClient';
|
||||
import type { IConfig, IPagerConfig } from '../../types';
|
||||
import type { AlertPayload } from './types';
|
||||
|
||||
const pdEvent = vi.hoisted(() => vi.fn(async () => ({})));
|
||||
vi.mock('@pagerduty/pdjs', () => ({ event: pdEvent }));
|
||||
|
||||
const makeClient = (pager: IPagerConfig = {}) =>
|
||||
new AlarmClient({ serverId: 'test-node', pager } as unknown as IConfig);
|
||||
|
||||
/** Register a capturing handler in place of a real transport. */
|
||||
const capture = (
|
||||
client: AlarmClient,
|
||||
minSeverity?: 'critical' | 'error' | 'warning' | 'info',
|
||||
maxSeverity?: 'critical' | 'error' | 'warning' | 'info',
|
||||
) => {
|
||||
const seen: AlertPayload[] = [];
|
||||
client.addAlertHandler(
|
||||
async (alert) => {
|
||||
seen.push(alert);
|
||||
},
|
||||
{ name: 'capture', minSeverity, maxSeverity },
|
||||
);
|
||||
return seen;
|
||||
};
|
||||
|
||||
describe('AlarmClient severity routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
pdEvent.mockClear();
|
||||
});
|
||||
|
||||
it('defaults to critical when the call site says nothing', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('boom', 'everything is on fire');
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].severity).toBe('critical');
|
||||
});
|
||||
|
||||
it('honours a configured default severity', () => {
|
||||
const client = makeClient({ defaultSeverity: 'info' });
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('boom', 'everything is on fire');
|
||||
|
||||
expect(seen[0].severity).toBe('info');
|
||||
});
|
||||
|
||||
it('skips handlers whose floor the alarm does not reach', () => {
|
||||
const client = makeClient();
|
||||
const paging = capture(client, 'warning');
|
||||
const chat = capture(client, 'info');
|
||||
|
||||
client.create('rate-limit', 'a user hit a limit', {}, 'info');
|
||||
|
||||
expect(paging).toHaveLength(0);
|
||||
expect(chat).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips handlers whose ceiling the alarm exceeds', () => {
|
||||
const client = makeClient();
|
||||
const chat = capture(client, 'info', 'info');
|
||||
|
||||
client.create('rate-limit', 'a user hit a limit', {}, 'info');
|
||||
client.create('outage', 'everything is on fire', {}, 'critical');
|
||||
|
||||
expect(chat.map((alert) => alert.id)).toEqual(['rate-limit']);
|
||||
});
|
||||
|
||||
it('retiers an alarm from config', () => {
|
||||
const client = makeClient({
|
||||
severityOverrides: { 'noisy:*': 'info' },
|
||||
});
|
||||
const paging = capture(client, 'warning');
|
||||
const chat = capture(client, 'info');
|
||||
|
||||
client.create('noisy:thing', 'used to page', {}, 'critical');
|
||||
|
||||
expect(paging).toHaveLength(0);
|
||||
expect(chat[0].severity).toBe('info');
|
||||
});
|
||||
|
||||
it('mutes an alarm from config', () => {
|
||||
const client = makeClient({
|
||||
severityOverrides: { 'noisy:thing': 'mute' },
|
||||
});
|
||||
const chat = capture(client, 'info');
|
||||
|
||||
client.create('noisy:thing', 'not worth reporting');
|
||||
client.create('noisy:thing', 'still not worth reporting');
|
||||
|
||||
expect(chat).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lets config override a known-error rule', () => {
|
||||
const client = makeClient({
|
||||
severityOverrides: { 'known:thing': 'critical' },
|
||||
});
|
||||
client.setKnownErrors([
|
||||
{
|
||||
match: { id: 'known:thing' },
|
||||
action: { type: 'severity', value: 'info' },
|
||||
},
|
||||
]);
|
||||
const paging = capture(client, 'warning');
|
||||
|
||||
client.create('known:thing', 'known but escalated');
|
||||
|
||||
expect(paging[0].severity).toBe('critical');
|
||||
});
|
||||
|
||||
it('still respects a no-alert known-error rule', () => {
|
||||
const client = makeClient();
|
||||
client.setKnownErrors([
|
||||
{ match: { id: 'known:quiet' }, action: { type: 'no-alert' } },
|
||||
]);
|
||||
const chat = capture(client, 'info');
|
||||
|
||||
client.create('known:quiet', 'suppressed');
|
||||
|
||||
expect(chat).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient alert payload', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('reports occurrence counts across repeats', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('flap', 'first');
|
||||
client.create('flap', 'second');
|
||||
|
||||
expect(seen[0]).toMatchObject({ repeatCount: 1, isRepeat: false });
|
||||
expect(seen[1]).toMatchObject({ repeatCount: 2, isRepeat: true });
|
||||
});
|
||||
|
||||
it('renders fields as strings and lifts the stack out of the error', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
const error = new Error('kaboom');
|
||||
|
||||
client.create('boom', 'failed', { error, status: 500 }, 'critical');
|
||||
|
||||
expect(seen[0].fields.status).toBe('500');
|
||||
expect(seen[0].trace).toBe(error.stack);
|
||||
expect(seen[0].shortId).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
});
|
||||
|
||||
it('keeps one failing handler from starving the others', async () => {
|
||||
const client = makeClient();
|
||||
client.addAlertHandler(
|
||||
async () => {
|
||||
throw new Error('transport down');
|
||||
},
|
||||
{ name: 'broken' },
|
||||
);
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('boom', 'failed');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('suppresses alarms once draining', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.onServerPrepareShutdown();
|
||||
client.create('boom', 'too late');
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient transport registration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
pdEvent.mockClear();
|
||||
});
|
||||
|
||||
it('splits info to Slack and everything above it to PagerDuty', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = makeClient({
|
||||
pagerduty: { enabled: true, routingKey: 'rk' },
|
||||
slack: { enabled: true, webhookUrl: 'https://hooks.example/abc' },
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
client.create('quiet:thing', 'informational', {}, 'info');
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(pdEvent).not.toHaveBeenCalled();
|
||||
|
||||
client.create('loud:thing', 'paging', {}, 'critical');
|
||||
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(1));
|
||||
// The pager has it; chat doesn't repeat it.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends every severity to Slack when there is no pager', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = makeClient({
|
||||
slack: { enabled: true, webhookUrl: 'https://hooks.example/abc' },
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
client.create(
|
||||
'loud:thing',
|
||||
'nowhere else to send this',
|
||||
{},
|
||||
'critical',
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('lets config widen the Slack ceiling back out', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const client = makeClient({
|
||||
pagerduty: { enabled: true, routingKey: 'rk' },
|
||||
slack: {
|
||||
enabled: true,
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
maxSeverity: 'critical',
|
||||
},
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
client.create('loud:thing', 'paging', {}, 'critical');
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('gives each occurrence its own PagerDuty incident by default', async () => {
|
||||
const client = makeClient({
|
||||
pagerduty: { enabled: true, routingKey: 'rk' },
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
client.create('scan:failed', 'first', {}, 'warning');
|
||||
client.create('scan:failed', 'second', {}, 'warning');
|
||||
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(2));
|
||||
|
||||
const keys = pdEvent.mock.calls.map(
|
||||
([arg]: [{ data: { dedup_key: string } }]) => arg.data.dedup_key,
|
||||
);
|
||||
expect(keys[0]).not.toBe(keys[1]);
|
||||
});
|
||||
|
||||
it('collapses repeats of a dedup alarm onto one incident', async () => {
|
||||
const client = makeClient({
|
||||
pagerduty: { enabled: true, routingKey: 'rk' },
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
const raise = () =>
|
||||
client.create(
|
||||
'http_500:POST:/notif/mark-ack:deadlock',
|
||||
'HTTP 500 on POST /notif/mark-ack: deadlock',
|
||||
{},
|
||||
'critical',
|
||||
{ dedup: true },
|
||||
);
|
||||
raise();
|
||||
raise();
|
||||
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(2));
|
||||
|
||||
const keys = pdEvent.mock.calls.map(
|
||||
([arg]: [{ data: { dedup_key: string } }]) => arg.data.dedup_key,
|
||||
);
|
||||
expect(keys[0]).toBe('http_500:POST:/notif/mark-ack:deadlock');
|
||||
expect(keys[1]).toBe(keys[0]);
|
||||
});
|
||||
|
||||
it('skips transports that are enabled but not configured', async () => {
|
||||
const client = makeClient({
|
||||
pagerduty: { enabled: true },
|
||||
slack: { enabled: true },
|
||||
});
|
||||
await client.onServerStart();
|
||||
|
||||
const seen = capture(client);
|
||||
client.create('boom', 'nowhere to send this');
|
||||
|
||||
// Only the capturing handler is registered.
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(pdEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient alarm registry', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('finds an alarm by its full id or its short id', () => {
|
||||
const client = makeClient();
|
||||
client.create('disk-pressure', 'nearly full');
|
||||
|
||||
const alarm = client.get('disk-pressure');
|
||||
expect(alarm?.message).toBe('nearly full');
|
||||
expect(client.get(alarm!.shortId)).toBe(alarm);
|
||||
});
|
||||
|
||||
it('returns nothing for an id it has never seen', () => {
|
||||
expect(makeClient().get('never-raised')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forgets an alarm once cleared, under both ids', () => {
|
||||
const client = makeClient();
|
||||
client.create('disk-pressure', 'nearly full');
|
||||
const shortId = client.get('disk-pressure')!.shortId;
|
||||
|
||||
client.clear('disk-pressure');
|
||||
|
||||
expect(client.get('disk-pressure')).toBeUndefined();
|
||||
expect(client.get(shortId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a clear for an alarm that is not active', () => {
|
||||
const client = makeClient();
|
||||
expect(() => client.clear('never-raised')).not.toThrow();
|
||||
});
|
||||
|
||||
it('abbreviates long ids in the log line but keeps the short id usable', () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const client = makeClient();
|
||||
const longId = 'a-very-long-alarm-identifier-that-wraps';
|
||||
|
||||
client.create(longId, 'noisy');
|
||||
|
||||
const shortId = client.get(longId)!.shortId;
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
`[alarm] ACTIVE ${shortId} (${longId.slice(0, 20)}...) :: noisy`,
|
||||
);
|
||||
});
|
||||
|
||||
it('accumulates fields across repeats of the same alarm', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('flap', 'first', { a: 1 });
|
||||
client.create('flap', 'second', { b: 2 });
|
||||
|
||||
expect(seen[1].fields).toEqual({ a: '1', b: '2' });
|
||||
expect(client.get('flap')?.occurrences).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('caps retained occurrences while still counting every repeat', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
client.create('hot', `occurrence ${i}`, { i });
|
||||
}
|
||||
|
||||
const alarm = client.get('hot')!;
|
||||
expect(alarm.count).toBe(50);
|
||||
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');
|
||||
// Trimming history must not rewind what the transports are told.
|
||||
expect(seen[49]).toMatchObject({ repeatCount: 50, isRepeat: true });
|
||||
});
|
||||
|
||||
it('names anonymous handlers by their registration order', () => {
|
||||
const client = makeClient();
|
||||
client.addAlertHandler(async () => {
|
||||
throw new Error('down');
|
||||
});
|
||||
expect(() => client.create('boom', 'x')).not.toThrow();
|
||||
});
|
||||
|
||||
it('only logs the drain notice once', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
log.mockClear();
|
||||
const client = makeClient();
|
||||
|
||||
client.onServerPrepareShutdown();
|
||||
// A second prepare must not restart the drain.
|
||||
client.onServerPrepareShutdown();
|
||||
client.create('a', 'x');
|
||||
client.create('b', 'y');
|
||||
|
||||
const drainLogs = log.mock.calls.filter(
|
||||
([message]) =>
|
||||
message === '[alarm] suppressing alarm while draining',
|
||||
);
|
||||
expect(drainLogs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('substitutes placeholders for an empty id and message', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
|
||||
client.create('', '');
|
||||
|
||||
expect(seen[0]).toMatchObject({
|
||||
id: 'something-bad',
|
||||
message: 'something bad happened',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlarmClient known-error rules', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('suppresses alerts for a matching rule', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
client.setKnownErrors([
|
||||
{ match: { id: 'noisy' }, action: { type: 'no-alert' } },
|
||||
]);
|
||||
|
||||
client.create('noisy', 'ignore me');
|
||||
client.create('noisy', 'ignore me again');
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('leaves alarms with a different id alone', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
client.setKnownErrors([
|
||||
{ match: { id: 'noisy' }, action: { type: 'no-alert' } },
|
||||
]);
|
||||
|
||||
client.create('real', 'page me');
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('only matches when the message matches too', () => {
|
||||
const rules = [
|
||||
{
|
||||
match: { id: 'timeout', message: 'upstream timed out' },
|
||||
action: { type: 'no-alert' as const },
|
||||
},
|
||||
];
|
||||
|
||||
// Separate clients: suppression is recorded on the alarm, so a
|
||||
// second occurrence of the same id would inherit it.
|
||||
const matching = makeClient();
|
||||
const matchingSeen = capture(matching);
|
||||
matching.setKnownErrors(rules);
|
||||
matching.create('timeout', 'upstream timed out');
|
||||
expect(matchingSeen).toHaveLength(0);
|
||||
|
||||
const differing = makeClient();
|
||||
const differingSeen = capture(differing);
|
||||
differing.setKnownErrors(rules);
|
||||
differing.create('timeout', 'something else entirely');
|
||||
expect(differingSeen.map((alert) => alert.message)).toEqual([
|
||||
'something else entirely',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only matches when every named field matches', () => {
|
||||
const client = makeClient();
|
||||
const seen = capture(client);
|
||||
client.setKnownErrors([
|
||||
{
|
||||
match: { id: 'http', fields: { status: 404 } },
|
||||
action: { type: 'no-alert' },
|
||||
},
|
||||
]);
|
||||
|
||||
client.create('http', 'not found', { status: 404 });
|
||||
client.create('http-2', 'server error', { status: 500 });
|
||||
|
||||
expect(seen.map((alert) => alert.id)).toEqual(['http-2']);
|
||||
});
|
||||
|
||||
it('retiers a matching alarm to a lower severity', () => {
|
||||
const client = makeClient();
|
||||
const paging = capture(client, 'error');
|
||||
const chat = capture(client, 'info');
|
||||
client.setKnownErrors([
|
||||
{
|
||||
match: { id: 'flaky' },
|
||||
action: { type: 'severity', value: 'info' },
|
||||
},
|
||||
]);
|
||||
|
||||
client.create('flaky', 'transient', {}, 'critical');
|
||||
|
||||
expect(paging).toHaveLength(0);
|
||||
expect(chat).toHaveLength(1);
|
||||
expect(chat[0].severity).toBe('info');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -20,64 +20,65 @@
|
||||
import { event as pdEvent } from '@pagerduty/pdjs';
|
||||
import { inspect } from 'node:util';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { IConfig } from '../../types';
|
||||
import type { IConfig, PagerSeverity, SeverityRule } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
meetsMinSeverity,
|
||||
resolveSeverityOverride,
|
||||
withinMaxSeverity,
|
||||
} from './severity';
|
||||
import { createSlackAlertHandler } from './slack';
|
||||
import type {
|
||||
Alarm,
|
||||
AlarmFields,
|
||||
AlarmOptions,
|
||||
AlertHandler,
|
||||
AlertPayload,
|
||||
KnownErrorRule,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
Alarm,
|
||||
AlarmFields,
|
||||
AlarmOptions,
|
||||
AlertHandler,
|
||||
AlertPayload,
|
||||
KnownErrorRule,
|
||||
} from './types';
|
||||
export type { PagerSeverity } from '../../types';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
export interface AlarmFields {
|
||||
error?: Error;
|
||||
[key: string]: unknown;
|
||||
interface RegisteredHandler {
|
||||
name: string;
|
||||
/** Lowest severity this transport accepts. */
|
||||
minSeverity: PagerSeverity;
|
||||
/** Highest severity this transport accepts. */
|
||||
maxSeverity: PagerSeverity;
|
||||
handler: AlertHandler;
|
||||
}
|
||||
|
||||
interface AlarmOccurrence {
|
||||
message: string;
|
||||
fields: AlarmFields;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
id: string;
|
||||
shortId: string;
|
||||
message: string;
|
||||
fields: AlarmFields;
|
||||
error?: Error;
|
||||
started: number;
|
||||
timestamps: number[];
|
||||
occurrences: AlarmOccurrence[];
|
||||
severity?: PagerSeverity;
|
||||
noAlert?: boolean;
|
||||
}
|
||||
|
||||
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface AlertPayload {
|
||||
id: string;
|
||||
message: string;
|
||||
source: string;
|
||||
severity: PagerSeverity;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type AlertHandler = (alert: AlertPayload) => Promise<void>;
|
||||
|
||||
interface KnownErrorRule {
|
||||
match: {
|
||||
id: string;
|
||||
message?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
};
|
||||
action: {
|
||||
type: 'no-alert' | 'severity';
|
||||
value?: PagerSeverity;
|
||||
};
|
||||
}
|
||||
/** Severity used when neither the call site nor config picks one. */
|
||||
const FALLBACK_SEVERITY: PagerSeverity = 'critical';
|
||||
/**
|
||||
* How many recent occurrences an alarm keeps. An alarm is never cleared unless
|
||||
* something calls `clear`, and a hot one repeats for as long as the fault lasts
|
||||
* — so retaining every occurrence means retaining every message and field set
|
||||
* it was ever raised with, request bodies and actors included, for the life of
|
||||
* the process. The last few are what a human reads; the rest is only a count,
|
||||
* and `count` keeps that.
|
||||
*/
|
||||
const OCCURRENCE_HISTORY_LIMIT = 20;
|
||||
/** 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. */
|
||||
const DEFAULT_SLACK_MAX_SEVERITY_WITH_PAGER: PagerSeverity = 'info';
|
||||
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic short identifier derived from an alarm ID.
|
||||
* Produces a readable 3-word slug like "amber-delta-fox".
|
||||
* Deterministic short identifier derived from an alarm ID. Produces a readable
|
||||
* 3-word slug like "amber-delta-fox".
|
||||
*/
|
||||
const WORD_POOL = [
|
||||
'alpha',
|
||||
@@ -169,13 +170,17 @@ function cleanFields(fields: AlarmFields): Record<string, string> {
|
||||
// -- AlarmClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manages system alarms and dispatches alerts to external paging
|
||||
* services (PagerDuty, or any registered handler).
|
||||
* Manages system alarms and routes them to alert transports by severity.
|
||||
*
|
||||
* Severity is the routing decision: each transport declares the severity window
|
||||
* it accepts, so `critical` pages on-call while `info` only reaches the chat
|
||||
* channel. Config can retier or mute any alarm id after the fact — see
|
||||
* `pager.severityOverrides` in {@link IConfig}.
|
||||
*/
|
||||
export class AlarmClient extends PuterClient {
|
||||
private alarms = new Map<string, Alarm>();
|
||||
private aliases = new Map<string, Alarm>();
|
||||
private alertHandlers: AlertHandler[] = [];
|
||||
private alertHandlers: RegisteredHandler[] = [];
|
||||
private knownErrors: KnownErrorRule[] = [];
|
||||
private draining = false;
|
||||
private drainLogged = false;
|
||||
@@ -187,39 +192,8 @@ export class AlarmClient extends PuterClient {
|
||||
// -- Lifecycle ----------------------------------------------------
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const pagerConf = this.config.pager;
|
||||
if (!pagerConf?.pagerduty?.enabled) return;
|
||||
|
||||
const routingKey = pagerConf.pagerduty.routingKey;
|
||||
if (!routingKey) {
|
||||
console.warn(
|
||||
'[alarm] PagerDuty enabled but no routingKey configured',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = this.config.serverId;
|
||||
|
||||
this.alertHandlers.push(async (alert) => {
|
||||
await pdEvent({
|
||||
data: {
|
||||
routing_key: routingKey,
|
||||
event_action: 'trigger',
|
||||
dedup_key: alert.id,
|
||||
payload: {
|
||||
summary: alert.message,
|
||||
source: alert.source,
|
||||
severity: alert.severity,
|
||||
custom_details: {
|
||||
...alert.custom,
|
||||
server_id: serverId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log('[alarm] PagerDuty handler registered');
|
||||
const paging = this.registerPagerDuty();
|
||||
this.registerSlack({ paging });
|
||||
}
|
||||
|
||||
override onServerPrepareShutdown(): void {
|
||||
@@ -228,22 +202,107 @@ export class AlarmClient extends PuterClient {
|
||||
console.log('[alarm] entering drain mode — suppressing new alarms');
|
||||
}
|
||||
|
||||
/** @returns Whether a working PagerDuty transport was registered. */
|
||||
private registerPagerDuty(): boolean {
|
||||
const pagerDutyConf = this.config.pager?.pagerduty;
|
||||
if (!pagerDutyConf?.enabled) return false;
|
||||
|
||||
const routingKey = pagerDutyConf.routingKey;
|
||||
if (!routingKey) {
|
||||
console.warn(
|
||||
'[alarm] PagerDuty enabled but no routingKey configured',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverId = this.config.serverId;
|
||||
const minSeverity =
|
||||
pagerDutyConf.minSeverity ?? DEFAULT_PAGERDUTY_MIN_SEVERITY;
|
||||
|
||||
this.addAlertHandler(
|
||||
async (alert) => {
|
||||
await pdEvent({
|
||||
data: {
|
||||
routing_key: routingKey,
|
||||
event_action: 'trigger',
|
||||
dedup_key: alert.dedupKey,
|
||||
payload: {
|
||||
summary: alert.message,
|
||||
source: alert.source,
|
||||
severity: alert.severity,
|
||||
custom_details: {
|
||||
...alert.custom,
|
||||
server_id: serverId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
{ name: 'pagerduty', minSeverity },
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[alarm] PagerDuty handler registered (min severity: ${minSeverity})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private registerSlack({ paging }: { paging: boolean }): void {
|
||||
const slackConf = this.config.pager?.slack;
|
||||
if (!slackConf?.enabled) return;
|
||||
|
||||
if (!slackConf.webhookUrl) {
|
||||
console.warn('[alarm] Slack enabled but no webhookUrl configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const minSeverity = slackConf.minSeverity ?? 'info';
|
||||
// With a pager taking everything from `warning` up, chat is where the
|
||||
// rest is recorded — reposting the paging tiers there only trains
|
||||
// people to skim the channel. Without one, Slack is the only place an
|
||||
// alarm can land, so it takes all of them.
|
||||
const maxSeverity =
|
||||
slackConf.maxSeverity ??
|
||||
(paging ? DEFAULT_SLACK_MAX_SEVERITY_WITH_PAGER : 'critical');
|
||||
|
||||
this.addAlertHandler(
|
||||
createSlackAlertHandler(slackConf, {
|
||||
serverId: this.config.serverId,
|
||||
}),
|
||||
{ name: 'slack', minSeverity, maxSeverity },
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[alarm] Slack handler registered (severity ${minSeverity}..${maxSeverity})`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Public API ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create or update an alarm. If the alarm ID already exists, the
|
||||
* occurrence count is incremented and a repeat alert is dispatched.
|
||||
* Create or update an alarm. If the alarm ID already exists, the occurrence
|
||||
* count is incremented and a repeat alert is dispatched.
|
||||
*
|
||||
* `severity` is the PagerDuty severity for this alarm; omit for the
|
||||
* default 'critical'. Use 'info' / 'warning' for expected-but-worth-
|
||||
* tracking signals (e.g. a user hitting a rate limit) so they record
|
||||
* and de-dupe like any other alarm but don't page on-call.
|
||||
* `severity` decides where the alarm lands:
|
||||
*
|
||||
* Critical — a real outage; pages on-call. Reserve it for unhandled server
|
||||
* errors. error — pages on-call as well; prefer `critical` or `warning`.
|
||||
* warning — worth a look soon, but nobody gets woken up. info — a record in
|
||||
* the chat channel; never pages.
|
||||
*
|
||||
* Omit it to take `pager.defaultSeverity` (itself defaulting to
|
||||
* 'critical'). Operators can retier or mute any alarm id from config
|
||||
* afterwards, so the value here is the starting point, not the last word.
|
||||
*
|
||||
* Pass `{ dedup: true }` when repeats of this id are one recurring fault
|
||||
* that should collapse into a single incident — see {@link AlarmOptions}.
|
||||
*/
|
||||
create(
|
||||
id: string,
|
||||
message: string,
|
||||
fields: AlarmFields = {},
|
||||
severity?: PagerSeverity,
|
||||
opts: AlarmOptions = {},
|
||||
): void {
|
||||
if (this.draining) {
|
||||
if (!this.drainLogged) {
|
||||
@@ -267,8 +326,12 @@ export class AlarmClient extends PuterClient {
|
||||
message,
|
||||
fields,
|
||||
severity,
|
||||
dedup: opts.dedup,
|
||||
started: Date.now(),
|
||||
timestamps: [Date.now()],
|
||||
// `recordOccurrence` below stamps the first occurrence; seeding one
|
||||
// here too would report every alarm as one occurrence ahead.
|
||||
count: 0,
|
||||
timestamps: [],
|
||||
occurrences: [],
|
||||
};
|
||||
if (fields.error) alarm.error = fields.error;
|
||||
@@ -295,16 +358,28 @@ export class AlarmClient extends PuterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an additional alert handler. Handlers are called for
|
||||
* every alarm that isn't suppressed by a known-error rule.
|
||||
* Register an additional alert handler. Handlers are called for every alarm
|
||||
* that isn't suppressed by a known-error rule or muted by config, and whose
|
||||
* severity falls inside the handler's own `minSeverity`..`maxSeverity`
|
||||
* window (default: everything).
|
||||
*/
|
||||
addAlertHandler(handler: AlertHandler): void {
|
||||
this.alertHandlers.push(handler);
|
||||
addAlertHandler(
|
||||
handler: AlertHandler,
|
||||
opts: {
|
||||
name?: string;
|
||||
minSeverity?: PagerSeverity;
|
||||
maxSeverity?: PagerSeverity;
|
||||
} = {},
|
||||
): void {
|
||||
this.alertHandlers.push({
|
||||
name: opts.name ?? `handler-${this.alertHandlers.length}`,
|
||||
minSeverity: opts.minSeverity ?? 'info',
|
||||
maxSeverity: opts.maxSeverity ?? 'critical',
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rules that can suppress or adjust severity of known errors.
|
||||
*/
|
||||
/** Add rules that can suppress or adjust severity of known errors. */
|
||||
setKnownErrors(rules: KnownErrorRule[]): void {
|
||||
this.knownErrors = rules;
|
||||
}
|
||||
@@ -316,16 +391,25 @@ export class AlarmClient extends PuterClient {
|
||||
message: string,
|
||||
fields: AlarmFields,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
alarm.message = message;
|
||||
alarm.fields = { ...alarm.fields, ...fields };
|
||||
alarm.timestamps.push(Date.now());
|
||||
alarm.count++;
|
||||
if (fields.error) alarm.error = fields.error;
|
||||
|
||||
alarm.occurrences.push({
|
||||
message,
|
||||
fields,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
alarm.timestamps.push(now);
|
||||
alarm.occurrences.push({ message, fields, timestamp: now });
|
||||
|
||||
if (alarm.timestamps.length > OCCURRENCE_HISTORY_LIMIT) {
|
||||
alarm.timestamps.splice(
|
||||
0,
|
||||
alarm.timestamps.length - OCCURRENCE_HISTORY_LIMIT,
|
||||
);
|
||||
alarm.occurrences.splice(
|
||||
0,
|
||||
alarm.occurrences.length - OCCURRENCE_HISTORY_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private applyKnownErrors(alarm: Alarm): void {
|
||||
@@ -373,7 +457,7 @@ export class AlarmClient extends PuterClient {
|
||||
this.applyKnownErrors(alarm);
|
||||
|
||||
console.warn(
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.timestamps.length})`,
|
||||
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.count})`,
|
||||
);
|
||||
|
||||
if (alarm.noAlert) return;
|
||||
@@ -381,25 +465,72 @@ export class AlarmClient extends PuterClient {
|
||||
this.dispatchAlert(alarm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call-site severity, then any known-error rule (both already on the
|
||||
* alarm), then the config override — so an operator always has the last
|
||||
* word over what the code asked for.
|
||||
*/
|
||||
private resolveSeverity(alarm: Alarm): SeverityRule {
|
||||
const base =
|
||||
alarm.severity ??
|
||||
this.config.pager?.defaultSeverity ??
|
||||
FALLBACK_SEVERITY;
|
||||
return (
|
||||
resolveSeverityOverride(
|
||||
alarm.id,
|
||||
this.config.pager?.severityOverrides,
|
||||
) ?? base
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchAlert(alarm: Alarm): void {
|
||||
const severity = alarm.severity ?? 'critical';
|
||||
const resolved = this.resolveSeverity(alarm);
|
||||
if (resolved === 'mute') {
|
||||
if (!alarm.muteLogged) {
|
||||
alarm.muteLogged = true;
|
||||
console.log(`[alarm] MUTED by config ${displayId(alarm)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
alarm.severity = resolved;
|
||||
|
||||
const fieldsClean = cleanFields(alarm.fields);
|
||||
const repeatCount = alarm.count;
|
||||
|
||||
const id = alarm.id || 'something-bad';
|
||||
|
||||
const payload: AlertPayload = {
|
||||
id: alarm.id || 'something-bad',
|
||||
id,
|
||||
// A de-duplicating transport should fold repeats of a recurring
|
||||
// fault into one incident, but everything else is a fresh event
|
||||
// each time it fires — the start time keeps occurrence numbering
|
||||
// from colliding with an incident left open by an earlier boot.
|
||||
dedupKey: alarm.dedup
|
||||
? id
|
||||
: `${id}#${alarm.started}.${repeatCount}`,
|
||||
shortId: alarm.shortId,
|
||||
message: alarm.message || alarm.id || 'something bad happened',
|
||||
source: 'alarm',
|
||||
severity,
|
||||
severity: resolved,
|
||||
fields: fieldsClean,
|
||||
trace: alarm.error?.stack,
|
||||
repeatCount,
|
||||
isRepeat: repeatCount > 1,
|
||||
custom: {
|
||||
fields: fieldsClean,
|
||||
trace: alarm.error?.stack,
|
||||
repeat_count: alarm.timestamps.length,
|
||||
repeat_count: repeatCount,
|
||||
},
|
||||
};
|
||||
|
||||
for (const handler of this.alertHandlers) {
|
||||
for (const { name, minSeverity, maxSeverity, handler } of this
|
||||
.alertHandlers) {
|
||||
if (!meetsMinSeverity(resolved, minSeverity)) continue;
|
||||
if (!withinMaxSeverity(resolved, maxSeverity)) continue;
|
||||
handler(payload).catch((err) => {
|
||||
console.error(`[alarm] alert handler failed: ${err?.message}`);
|
||||
console.error(
|
||||
`[alarm] ${name} alert handler failed: ${err?.message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { meetsMinSeverity, resolveSeverityOverride } from './severity';
|
||||
|
||||
describe('meetsMinSeverity', () => {
|
||||
it('accepts everything at or above the floor', () => {
|
||||
expect(meetsMinSeverity('critical', 'warning')).toBe(true);
|
||||
expect(meetsMinSeverity('error', 'warning')).toBe(true);
|
||||
expect(meetsMinSeverity('warning', 'warning')).toBe(true);
|
||||
expect(meetsMinSeverity('info', 'warning')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets an info floor take every severity', () => {
|
||||
for (const severity of [
|
||||
'critical',
|
||||
'error',
|
||||
'warning',
|
||||
'info',
|
||||
] as const) {
|
||||
expect(meetsMinSeverity(severity, 'info')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSeverityOverride', () => {
|
||||
it('returns undefined without overrides or on no match', () => {
|
||||
expect(resolveSeverityOverride('a:b', undefined)).toBeUndefined();
|
||||
expect(
|
||||
resolveSeverityOverride('a:b', { 'c:*': 'info' }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches an exact id', () => {
|
||||
expect(
|
||||
resolveSeverityOverride('cronMonitor:lowSignupRate', {
|
||||
'cronMonitor:lowSignupRate': 'info',
|
||||
}),
|
||||
).toBe('info');
|
||||
});
|
||||
|
||||
it('matches a prefix pattern', () => {
|
||||
expect(
|
||||
resolveSeverityOverride('cronMonitor:high_aiLogEntries', {
|
||||
'cronMonitor:*': 'warning',
|
||||
}),
|
||||
).toBe('warning');
|
||||
});
|
||||
|
||||
it('prefers the exact id over a prefix pattern', () => {
|
||||
expect(
|
||||
resolveSeverityOverride('cronMonitor:lowSignupRate', {
|
||||
'cronMonitor:*': 'warning',
|
||||
'cronMonitor:lowSignupRate': 'mute',
|
||||
}),
|
||||
).toBe('mute');
|
||||
});
|
||||
|
||||
it('prefers the longest matching prefix', () => {
|
||||
expect(
|
||||
resolveSeverityOverride('abuse:card-verification:setup-failed', {
|
||||
'abuse:*': 'info',
|
||||
'abuse:card-verification:*': 'mute',
|
||||
}),
|
||||
).toBe('mute');
|
||||
});
|
||||
|
||||
it('drops an unrecognized rule rather than guessing', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(
|
||||
resolveSeverityOverride('a:b', {
|
||||
'a:b': 'silent' as unknown as 'mute',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { PagerSeverity, SeverityRule } from '../../types';
|
||||
|
||||
/** Ascending urgency. A transport takes everything at or above its floor. */
|
||||
const RANK: Record<PagerSeverity, number> = {
|
||||
info: 0,
|
||||
warning: 1,
|
||||
error: 2,
|
||||
critical: 3,
|
||||
};
|
||||
|
||||
const SEVERITIES = Object.keys(RANK) as PagerSeverity[];
|
||||
|
||||
export function isPagerSeverity(value: unknown): value is PagerSeverity {
|
||||
return typeof value === 'string' && value in RANK;
|
||||
}
|
||||
|
||||
/** True when `severity` is urgent enough for a transport whose floor is `min`. */
|
||||
export function meetsMinSeverity(
|
||||
severity: PagerSeverity,
|
||||
min: PagerSeverity,
|
||||
): boolean {
|
||||
return RANK[severity] >= RANK[min];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `severity` is quiet enough for a transport whose ceiling is `max`.
|
||||
* A ceiling is what keeps a chat transport from repeating everything the pager
|
||||
* already delivered.
|
||||
*/
|
||||
export function withinMaxSeverity(
|
||||
severity: PagerSeverity,
|
||||
max: PagerSeverity,
|
||||
): boolean {
|
||||
return RANK[severity] <= RANK[max];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the operator override for an alarm id. Exact ids win over prefix
|
||||
* patterns (`cronMonitor:*`); among patterns the longest prefix wins, so a
|
||||
* specific rule can carve an exception out of a broad one.
|
||||
*/
|
||||
export function resolveSeverityOverride(
|
||||
id: string,
|
||||
overrides: Record<string, SeverityRule> | undefined,
|
||||
): SeverityRule | undefined {
|
||||
if (!overrides) return undefined;
|
||||
|
||||
const exact = overrides[id];
|
||||
if (exact !== undefined) return validRule(id, exact);
|
||||
|
||||
let bestLength = -1;
|
||||
let best: SeverityRule | undefined;
|
||||
for (const [pattern, rule] of Object.entries(overrides)) {
|
||||
if (!pattern.endsWith('*')) continue;
|
||||
const prefix = pattern.slice(0, -1);
|
||||
if (!id.startsWith(prefix)) continue;
|
||||
if (prefix.length <= bestLength) continue;
|
||||
bestLength = prefix.length;
|
||||
best = rule;
|
||||
}
|
||||
return best === undefined ? undefined : validRule(id, best);
|
||||
}
|
||||
|
||||
/**
|
||||
* A typo in the override map would otherwise silently mute or escalate an
|
||||
* alarm, so an unrecognized value is dropped with a warning instead.
|
||||
*/
|
||||
function validRule(id: string, rule: SeverityRule): SeverityRule | undefined {
|
||||
if (rule === 'mute' || isPagerSeverity(rule)) return rule;
|
||||
console.warn(
|
||||
`[alarm] ignoring invalid severity override "${rule}" for ${id} ` +
|
||||
`(expected mute or one of ${SEVERITIES.join(', ')})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildSlackMessage, createSlackAlertHandler } from './slack';
|
||||
import type { AlertPayload } from './types';
|
||||
|
||||
const alert = (over: Partial<AlertPayload> = {}): AlertPayload => ({
|
||||
id: 'cronMonitor:high_aiLogEntries',
|
||||
dedupKey: 'cronMonitor:high_aiLogEntries#1.1',
|
||||
shortId: 'amber-delta-fox',
|
||||
message: 'High AI log entries: 1200 in the last 10 minutes',
|
||||
source: 'alarm',
|
||||
severity: 'warning',
|
||||
fields: { count: '1200', threshold: '1000' },
|
||||
repeatCount: 1,
|
||||
isRepeat: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildSlackMessage', () => {
|
||||
it('renders severity, message and fields', () => {
|
||||
const msg = buildSlackMessage(alert(), {
|
||||
channel: '#alerts',
|
||||
username: 'puter-alarms',
|
||||
serverId: 'oregon',
|
||||
});
|
||||
|
||||
expect(msg.text).toContain('[WARNING]');
|
||||
expect(msg.text).toContain('High AI log entries');
|
||||
expect(msg.channel).toBe('#alerts');
|
||||
expect(msg.username).toBe('puter-alarms');
|
||||
expect(msg.attachments[0].fields).toEqual([
|
||||
{ title: 'count', value: '1200', short: true },
|
||||
{ title: 'threshold', value: '1000', short: true },
|
||||
]);
|
||||
expect(msg.attachments[0].footer).toBe(
|
||||
'amber-delta-fox • cronMonitor:high_aiLogEntries • oregon',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks repeats with an occurrence count', () => {
|
||||
const msg = buildSlackMessage(
|
||||
alert({ repeatCount: 7, isRepeat: true }),
|
||||
);
|
||||
expect(msg.text).toContain('(x7)');
|
||||
});
|
||||
|
||||
it('colours each severity differently', () => {
|
||||
const colors = (['critical', 'error', 'warning', 'info'] as const).map(
|
||||
(severity) =>
|
||||
buildSlackMessage(alert({ severity })).attachments[0].color,
|
||||
);
|
||||
expect(new Set(colors).size).toBe(4);
|
||||
});
|
||||
|
||||
it('puts the stack in a code block and keeps it out of the fields', () => {
|
||||
const msg = buildSlackMessage(
|
||||
alert({
|
||||
fields: { error: 'Error: boom', path: '/api/x' },
|
||||
trace: 'Error: boom\n at handler',
|
||||
}),
|
||||
);
|
||||
expect(msg.attachments[0].text).toBe(
|
||||
'```Error: boom\n at handler```',
|
||||
);
|
||||
expect(msg.attachments[0].fields.map((f) => f.title)).toEqual(['path']);
|
||||
});
|
||||
|
||||
it('truncates long values and caps the field count', () => {
|
||||
const fields: Record<string, string> = { long: 'x'.repeat(1000) };
|
||||
for (let i = 0; i < 30; i++) fields[`f${i}`] = String(i);
|
||||
|
||||
const msg = buildSlackMessage(alert({ fields }));
|
||||
expect(msg.attachments[0].fields.length).toBe(12);
|
||||
expect(msg.attachments[0].fields[0].value).toHaveLength(400);
|
||||
expect(msg.attachments[0].fields[0].value.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits channel and username when unset', () => {
|
||||
const msg = buildSlackMessage(alert());
|
||||
expect(msg.channel).toBeUndefined();
|
||||
expect(msg.username).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSlackAlertHandler', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('posts the payload to the webhook', async () => {
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
channel: '#alerts',
|
||||
});
|
||||
await handler(alert());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://hooks.example/abc');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body).channel).toBe('#alerts');
|
||||
});
|
||||
|
||||
it('throttles repeats of the same alarm and lets other ids through', async () => {
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: 60_000,
|
||||
});
|
||||
|
||||
await handler(alert());
|
||||
await handler(alert({ repeatCount: 2, isRepeat: true }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await handler(alert({ id: 'other:alarm' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await handler(alert({ repeatCount: 3, isRepeat: true }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('posts every occurrence when throttling is disabled', async () => {
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: 0,
|
||||
});
|
||||
|
||||
await handler(alert());
|
||||
await handler(alert({ repeatCount: 2, isRepeat: true }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not let a failed post consume the throttle slot', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(handler(alert())).rejects.toThrow('500');
|
||||
await handler(alert());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('releases the throttle slot when the post itself errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('socket hang up'));
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(handler(alert())).rejects.toThrow('socket hang up');
|
||||
await handler(alert());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('evicts throttle entries that have aged out once the map fills', async () => {
|
||||
const throttleMs = 60_000;
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: throttleMs,
|
||||
});
|
||||
|
||||
// Fill to the high-water mark, then age every entry past the window.
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
await handler(alert({ id: `aged:${i}` }));
|
||||
}
|
||||
vi.advanceTimersByTime(throttleMs);
|
||||
|
||||
await handler(alert({ id: 'fresh' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5001);
|
||||
|
||||
// The aged ids were pruned, so they post again immediately.
|
||||
await handler(alert({ id: 'aged:0' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5002);
|
||||
});
|
||||
|
||||
it('drops the oldest entry when the map is full of live ones', async () => {
|
||||
const handler = createSlackAlertHandler({
|
||||
webhookUrl: 'https://hooks.example/abc',
|
||||
repeatThrottleMs: 60_000,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
await handler(alert({ id: `live:${i}` }));
|
||||
}
|
||||
|
||||
// Nothing has aged out, so the oldest id makes room for the new one.
|
||||
await handler(alert({ id: 'newcomer' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5001);
|
||||
|
||||
await handler(alert({ id: 'live:0' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5002);
|
||||
// The newcomer is still throttled.
|
||||
await handler(alert({ id: 'newcomer' }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5002);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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 { ISlackAlertConfig, PagerSeverity } from '../../types';
|
||||
import type { AlertHandler, AlertPayload } from './types';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
export const DEFAULT_REPEAT_THROTTLE_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Beyond this many tracked alarm ids, throttle state is pruned. */
|
||||
const MAX_THROTTLE_ENTRIES = 5000;
|
||||
|
||||
const MAX_FIELDS = 12;
|
||||
const MAX_FIELD_VALUE = 400;
|
||||
const MAX_TRACE = 1500;
|
||||
/** Values under this length sit two-per-row in the Slack attachment. */
|
||||
const SHORT_FIELD_LENGTH = 40;
|
||||
|
||||
const STYLE: Record<PagerSeverity, { emoji: string; color: string }> = {
|
||||
critical: { emoji: ':rotating_light:', color: '#d64545' },
|
||||
error: { emoji: ':red_circle:', color: '#e08d4c' },
|
||||
warning: { emoji: ':warning:', color: '#e0b84c' },
|
||||
info: { emoji: ':information_source:', color: '#4c8de0' },
|
||||
};
|
||||
|
||||
interface SlackField {
|
||||
title: string;
|
||||
value: string;
|
||||
short: boolean;
|
||||
}
|
||||
|
||||
export interface SlackMessage {
|
||||
text: string;
|
||||
channel?: string;
|
||||
username?: string;
|
||||
icon_emoji?: string;
|
||||
attachments: Array<{
|
||||
color: string;
|
||||
fallback: string;
|
||||
fields: SlackField[];
|
||||
footer: string;
|
||||
mrkdwn_in: string[];
|
||||
text?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an alert as an incoming-webhook payload: severity-coloured attachment,
|
||||
* the alarm's fields as a table, and the stack (when there is one) as a code
|
||||
* block. Split out from the handler so the formatting is testable on its own.
|
||||
*/
|
||||
export function buildSlackMessage(
|
||||
alert: AlertPayload,
|
||||
opts: { channel?: string; username?: string; serverId?: string } = {},
|
||||
): SlackMessage {
|
||||
const style = STYLE[alert.severity] ?? STYLE.info;
|
||||
const repeat = alert.isRepeat ? ` (x${alert.repeatCount})` : '';
|
||||
const headline = `${style.emoji} *[${alert.severity.toUpperCase()}]* ${alert.message}${repeat}`;
|
||||
|
||||
const fields: SlackField[] = [];
|
||||
for (const [key, value] of Object.entries(alert.fields)) {
|
||||
if (fields.length >= MAX_FIELDS) break;
|
||||
// `error` is already rendered as the trace block below.
|
||||
if (key === 'error') continue;
|
||||
const rendered = truncate(value, MAX_FIELD_VALUE);
|
||||
fields.push({
|
||||
title: key,
|
||||
value: rendered,
|
||||
short: rendered.length <= SHORT_FIELD_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
const footerParts = [alert.shortId, alert.id];
|
||||
if (opts.serverId) footerParts.push(opts.serverId);
|
||||
|
||||
return {
|
||||
text: headline,
|
||||
...(opts.channel ? { channel: opts.channel } : {}),
|
||||
...(opts.username ? { username: opts.username } : {}),
|
||||
attachments: [
|
||||
{
|
||||
color: style.color,
|
||||
fallback: `[${alert.severity}] ${alert.message}`,
|
||||
fields,
|
||||
footer: footerParts.join(' • '),
|
||||
mrkdwn_in: ['text'],
|
||||
...(alert.trace
|
||||
? { text: '```' + truncate(alert.trace, MAX_TRACE) + '```' }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post alerts to a Slack incoming webhook. Repeats of the same alarm id are
|
||||
* throttled so a hot loop doesn't flood the channel — the occurrence count on
|
||||
* the next post that gets through tells the reader what they missed.
|
||||
*/
|
||||
export function createSlackAlertHandler(
|
||||
conf: ISlackAlertConfig,
|
||||
opts: { serverId?: string } = {},
|
||||
): AlertHandler {
|
||||
const webhookUrl = conf.webhookUrl as string;
|
||||
const throttleMs = conf.repeatThrottleMs ?? DEFAULT_REPEAT_THROTTLE_MS;
|
||||
const lastPosted = new Map<string, number>();
|
||||
|
||||
const shouldPost = (alert: AlertPayload): boolean => {
|
||||
if (throttleMs <= 0) return true;
|
||||
const now = Date.now();
|
||||
const previous = lastPosted.get(alert.id);
|
||||
if (previous !== undefined && now - previous < throttleMs) return false;
|
||||
|
||||
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
|
||||
for (const [id, at] of lastPosted) {
|
||||
if (now - at >= throttleMs) lastPosted.delete(id);
|
||||
}
|
||||
// Still full of live entries — drop the oldest to stay bounded.
|
||||
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
|
||||
const oldest = lastPosted.keys().next().value;
|
||||
if (oldest !== undefined) lastPosted.delete(oldest);
|
||||
}
|
||||
}
|
||||
lastPosted.set(alert.id, now);
|
||||
return true;
|
||||
};
|
||||
|
||||
return async (alert) => {
|
||||
if (!shouldPost(alert)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
buildSlackMessage(alert, {
|
||||
channel: conf.channel,
|
||||
username: conf.username,
|
||||
serverId: opts.serverId,
|
||||
}),
|
||||
),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// A rejected post shouldn't leave the id marked as delivered.
|
||||
lastPosted.delete(alert.id);
|
||||
throw new Error(`Slack webhook returned ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
lastPosted.delete(alert.id);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { PagerSeverity } from '../../types';
|
||||
|
||||
export interface AlarmFields {
|
||||
error?: Error;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AlarmOccurrence {
|
||||
message: string;
|
||||
fields: AlarmFields;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface AlarmOptions {
|
||||
/**
|
||||
* Collapse repeats of this alarm id into a single incident on transports
|
||||
* that de-duplicate (PagerDuty). Only right when the id already pins down
|
||||
* one recurring fault — an uncaught error on a route, say — so that N
|
||||
* occurrences really are one thing to fix. Off by default: every other
|
||||
* alarm raises its own incident per occurrence.
|
||||
*/
|
||||
dedup?: boolean;
|
||||
}
|
||||
|
||||
export interface Alarm extends AlarmOptions {
|
||||
id: string;
|
||||
shortId: string;
|
||||
message: string;
|
||||
fields: AlarmFields;
|
||||
error?: Error;
|
||||
started: number;
|
||||
/**
|
||||
* Every occurrence ever counted, including those aged out of the two lists
|
||||
* below.
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Timestamps of the most recent occurrences only — see
|
||||
* `OCCURRENCE_HISTORY_LIMIT`.
|
||||
*/
|
||||
timestamps: number[];
|
||||
/** The most recent occurrences only — see `OCCURRENCE_HISTORY_LIMIT`. */
|
||||
occurrences: AlarmOccurrence[];
|
||||
severity?: PagerSeverity;
|
||||
noAlert?: boolean;
|
||||
/** Set once a config mute has been logged, so it's reported only once. */
|
||||
muteLogged?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertPayload {
|
||||
id: string;
|
||||
/**
|
||||
* Key a de-duplicating transport groups by. Equal to `id` for alarms raised
|
||||
* with `dedup`, and unique per occurrence for everything else.
|
||||
*/
|
||||
dedupKey: string;
|
||||
/** Readable slug for the same alarm, for humans quoting it back. */
|
||||
shortId: string;
|
||||
message: string;
|
||||
source: string;
|
||||
severity: PagerSeverity;
|
||||
/** Field values rendered as strings, ready to display. */
|
||||
fields: Record<string, string>;
|
||||
/** Stack of the attached error, when the alarm carried one. */
|
||||
trace?: string;
|
||||
/** Total occurrences of this alarm so far, including this one. */
|
||||
repeatCount: number;
|
||||
/** False for the first occurrence of an alarm id. */
|
||||
isRepeat: boolean;
|
||||
/** PagerDuty `custom_details` payload. */
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type AlertHandler = (alert: AlertPayload) => Promise<void>;
|
||||
|
||||
export interface KnownErrorRule {
|
||||
match: {
|
||||
id: string;
|
||||
message?: string;
|
||||
fields?: Record<string, unknown>;
|
||||
};
|
||||
action: {
|
||||
type: 'no-alert' | 'severity';
|
||||
value?: PagerSeverity;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import { MySQLDatabaseClient } from './MySQLDatabaseClient.js';
|
||||
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
const config = (engine: string): IConfig =>
|
||||
({ port: 0, extensions: [], database: { engine } }) as IConfig;
|
||||
|
||||
const sqlite = () => new SqliteDatabaseClient(config('sqlite'));
|
||||
const mysql = () => new MySQLDatabaseClient(config('mysql'));
|
||||
const postgres = () => new PostgresDatabaseClient(config('postgres'));
|
||||
|
||||
/**
|
||||
* Minimal concrete client backed by fixture rows — exercises the shared helpers
|
||||
* on `AbstractDatabaseClient` that the real engines inherit.
|
||||
*/
|
||||
class FixtureDatabaseClient extends AbstractDatabaseClient {
|
||||
override readonly engineName = 'fixture';
|
||||
readonly reads: string[] = [];
|
||||
readonly preads: string[] = [];
|
||||
readonly writes: { query: string; params: unknown[] }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly replicaRows: Record<string, unknown>[] | Error,
|
||||
private readonly primaryRows: Record<string, unknown>[] = [],
|
||||
) {
|
||||
super(config('fixture'));
|
||||
}
|
||||
|
||||
override async read(query: string): Promise<Record<string, unknown>[]> {
|
||||
this.reads.push(query);
|
||||
if (this.replicaRows instanceof Error) throw this.replicaRows;
|
||||
return this.replicaRows;
|
||||
}
|
||||
|
||||
override async pread(query: string): Promise<Record<string, unknown>[]> {
|
||||
this.preads.push(query);
|
||||
return this.primaryRows;
|
||||
}
|
||||
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<WriteResult> {
|
||||
this.writes.push({ query, params });
|
||||
return { insertId: 1, affectedRows: 1, anyRowsAffected: true };
|
||||
}
|
||||
}
|
||||
|
||||
describe('DatabaseClientFactory', () => {
|
||||
it('picks sqlite when no engine is configured', () => {
|
||||
expect(
|
||||
new DatabaseClientFactory({ port: 0, extensions: [] } as IConfig),
|
||||
).toBeInstanceOf(SqliteDatabaseClient);
|
||||
});
|
||||
|
||||
it('refuses an unknown engine name', () => {
|
||||
expect(() => new DatabaseClientFactory(config('cassandra'))).toThrow(
|
||||
'Unknown database engine: cassandra',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbstractDatabaseClient — unimplemented surface', () => {
|
||||
const bare = () => new AbstractDatabaseClient(config('none'));
|
||||
|
||||
it('refuses to run queries until a subclass implements them', async () => {
|
||||
await expect(bare().read('SELECT 1')).rejects.toThrow(
|
||||
'DatabaseClient.read() not implemented',
|
||||
);
|
||||
await expect(bare().pread('SELECT 1')).rejects.toThrow(
|
||||
'DatabaseClient.pread() not implemented',
|
||||
);
|
||||
await expect(bare().write('DELETE FROM x')).rejects.toThrow(
|
||||
'DatabaseClient.write() not implemented',
|
||||
);
|
||||
await expect(bare().batchWrite([])).rejects.toThrow(
|
||||
'DatabaseClient.batchWrite() not implemented',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbstractDatabaseClient — replica-aware reads', () => {
|
||||
it('returns replica rows without waiting on the primary', async () => {
|
||||
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ id: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the primary when the replica has not caught up', async () => {
|
||||
const client = new FixtureDatabaseClient([], [{ id: 2 }]);
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ id: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the primary when the replica read throws', async () => {
|
||||
const client = new FixtureDatabaseClient(
|
||||
new Error('replica unavailable'),
|
||||
[{ id: 3 }],
|
||||
);
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ id: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the failing query when a required read finds nothing', async () => {
|
||||
const client = new FixtureDatabaseClient([], []);
|
||||
await expect(
|
||||
client.requireRead('SELECT * FROM `user` WHERE `id` = ?'),
|
||||
).rejects.toThrow(
|
||||
'required read returned no rows: SELECT * FROM `user` WHERE `id` = ?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbstractDatabaseClient — SQL generation', () => {
|
||||
it('generates an INSERT with quoted columns and placeholders', async () => {
|
||||
const client = new FixtureDatabaseClient([]);
|
||||
await client.insert('user', { username: 'ada', email: null });
|
||||
|
||||
expect(client.writes).toEqual([
|
||||
{
|
||||
query: 'INSERT INTO `user` (`username`, `email`) VALUES (?, ?)',
|
||||
params: ['ada', null],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('quotes each dotted segment and escapes embedded backticks', () => {
|
||||
const client = sqlite();
|
||||
expect(client.quoteIdentifier('user')).toBe('`user`');
|
||||
expect(client.quoteIdentifier('db.user.id')).toBe('`db`.`user`.`id`');
|
||||
expect(client.quoteIdentifier('user.*')).toBe('`user`.*');
|
||||
expect(client.quoteIdentifier('we`ird')).toBe('`we``ird`');
|
||||
});
|
||||
|
||||
it('quotes postgres identifiers with double quotes', () => {
|
||||
const client = postgres();
|
||||
expect(client.quoteIdentifier('db.user')).toBe('"db"."user"');
|
||||
expect(client.quoteIdentifier('we"ird')).toBe('"we""ird"');
|
||||
expect(client.quoteIdentifier('user.*')).toBe('"user".*');
|
||||
});
|
||||
|
||||
it('renders booleans the way each engine expects', () => {
|
||||
expect(sqlite().booleanLiteral(true)).toBe('1');
|
||||
expect(sqlite().booleanValue(false)).toBe(0);
|
||||
expect(postgres().booleanLiteral(true)).toBe('TRUE');
|
||||
expect(postgres().booleanLiteral(false)).toBe('FALSE');
|
||||
expect(postgres().booleanValue(true)).toBe(true);
|
||||
});
|
||||
|
||||
it('picks the engine-specific ignore-conflict syntax', () => {
|
||||
expect(sqlite().insertIgnoreInto('kv')).toBe(
|
||||
'INSERT OR IGNORE INTO `kv`',
|
||||
);
|
||||
expect(sqlite().insertIgnoreSuffix()).toBe('');
|
||||
expect(postgres().insertIgnoreInto('kv')).toBe('INSERT INTO "kv"');
|
||||
expect(postgres().insertIgnoreSuffix()).toBe(' ON CONFLICT DO NOTHING');
|
||||
expect(mysql().insertIgnoreInto('kv')).toBe('INSERT IGNORE INTO `kv`');
|
||||
expect(mysql().insertIgnoreSuffix()).toBe('');
|
||||
});
|
||||
|
||||
it('builds an upsert clause per engine', () => {
|
||||
expect(mysql().upsertClause(['user_id'], ['value', 'dt'])).toBe(
|
||||
'ON DUPLICATE KEY UPDATE `value` = ?, `dt` = ?',
|
||||
);
|
||||
expect(sqlite().upsertClause(['user_id', 'app'], ['value'])).toBe(
|
||||
'ON CONFLICT(`user_id`, `app`) DO UPDATE SET `value` = ?',
|
||||
);
|
||||
expect(postgres().upsertClause(['user_id'], ['value'])).toBe(
|
||||
'ON CONFLICT("user_id") DO UPDATE SET "value" = ?',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an upsert with nothing to update', () => {
|
||||
expect(() => sqlite().upsertClause(['user_id'], [])).toThrow(
|
||||
'upsertClause requires at least one update column',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts JSON text using each engine dialect', () => {
|
||||
expect(sqlite().jsonTextExtract('`metadata`', ['a', 'b'])).toBe(
|
||||
"json_extract(`metadata`, '$.a.b')",
|
||||
);
|
||||
expect(mysql().jsonTextExtract('`metadata`', ['a'])).toBe(
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.a'))",
|
||||
);
|
||||
expect(postgres().jsonTextExtract('"metadata"', ['a', 'b'])).toBe(
|
||||
`"metadata" #>> ARRAY['a', 'b']`,
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes quotes inside JSON path segments', () => {
|
||||
expect(sqlite().jsonTextExtract('`m`', ["it's"])).toBe(
|
||||
"json_extract(`m`, '$.it''s')",
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces expressions and rejects an empty list', () => {
|
||||
expect(sqlite().nullCoalesce('`a`', '`b`', '0')).toBe(
|
||||
'COALESCE(`a`, `b`, 0)',
|
||||
);
|
||||
expect(() => sqlite().nullCoalesce()).toThrow(
|
||||
'nullCoalesce requires at least one expression',
|
||||
);
|
||||
});
|
||||
|
||||
it('only postgres needs a RETURNING clause to learn the insert id', () => {
|
||||
expect(postgres().returningIdClause()).toBe(' RETURNING id');
|
||||
expect(sqlite().returningIdClause()).toBe('');
|
||||
expect(mysql().returningIdClause()).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to `otherwise` for an engine with no explicit choice', () => {
|
||||
const client = new FixtureDatabaseClient([]);
|
||||
expect(
|
||||
client.case({ sqlite: 'a', mysql: 'b', otherwise: 'fallback' }),
|
||||
).toBe('fallback');
|
||||
expect(client.case({ fixture: 'exact', otherwise: 'fallback' })).toBe(
|
||||
'exact',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -37,8 +37,8 @@ type SqlJsonPath = readonly [string, ...string[]];
|
||||
* Base database client. Subclasses must override every method that throws here.
|
||||
*
|
||||
* Do not instantiate directly — use the factory exported from
|
||||
* `clients/database/index.ts` which picks the right implementation
|
||||
* based on `config.database.engine`.
|
||||
* `clients/database/index.ts` which picks the right implementation based on
|
||||
* `config.database.engine`.
|
||||
*/
|
||||
export class AbstractDatabaseClient extends PuterClient {
|
||||
/** Short name used by `case()` to pick engine-specific values. */
|
||||
@@ -52,9 +52,7 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
// Abstract interface — subclasses MUST override
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Execute a read query. Returns an array of row objects.
|
||||
*/
|
||||
/** Execute a read query. Returns an array of row objects. */
|
||||
async read(
|
||||
_query: string,
|
||||
_params: unknown[] = [],
|
||||
@@ -63,9 +61,9 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read that prefers the primary database (useful when read-replicas
|
||||
* may have replication lag). In single-node setups this is identical
|
||||
* to `read()`.
|
||||
* Read that prefers the primary database (useful when read-replicas may
|
||||
* have replication lag). In single-node setups this is identical to
|
||||
* `read()`.
|
||||
*/
|
||||
async pread(
|
||||
_query: string,
|
||||
@@ -74,16 +72,12 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
throw new Error('DatabaseClient.pread() not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a write query (INSERT / UPDATE / DELETE).
|
||||
*/
|
||||
/** Execute a write query (INSERT / UPDATE / DELETE). */
|
||||
async write(_query: string, _params: unknown[] = []): Promise<WriteResult> {
|
||||
throw new Error('DatabaseClient.write() not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple write statements in a single transaction.
|
||||
*/
|
||||
/** Execute multiple write statements in a single transaction. */
|
||||
async batchWrite(_entries: BatchEntry[]): Promise<void> {
|
||||
throw new Error('DatabaseClient.batchWrite() not implemented');
|
||||
}
|
||||
@@ -111,9 +105,9 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `read()` but falls back to the primary when read-replicas are
|
||||
* in use. Subclasses may override with replica-aware logic; the
|
||||
* default delegates to `pread()`.
|
||||
* Like `read()` but falls back to the primary when read-replicas are in
|
||||
* use. Subclasses may override with replica-aware logic; the default
|
||||
* delegates to `pread()`.
|
||||
*/
|
||||
async tryHardRead(
|
||||
query: string,
|
||||
@@ -133,9 +127,7 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
return primary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `tryHardRead()` but throws when the result set is empty.
|
||||
*/
|
||||
/** Like `tryHardRead()` but throws when the result set is empty. */
|
||||
async requireRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -151,9 +143,12 @@ export class AbstractDatabaseClient extends PuterClient {
|
||||
* Return the value from `choices` that matches the current engine.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* db.case({ sqlite: "datetime('now')", mysql: 'NOW()', otherwise: 'NOW()' })
|
||||
* ```
|
||||
*
|
||||
* db.case({
|
||||
* sqlite: "datetime('now')",
|
||||
* mysql: 'NOW()',
|
||||
* otherwise: 'NOW()',
|
||||
* });
|
||||
*
|
||||
* If the engine name isn't present in `choices`, falls back to
|
||||
* `choices.otherwise`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -17,8 +17,94 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareMigrationFilenames } from './MySQLDatabaseClient.js';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import {
|
||||
MySQLDatabaseClient,
|
||||
compareMigrationFilenames,
|
||||
} from './MySQLDatabaseClient.js';
|
||||
|
||||
interface FakeConnection {
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
beginTransaction: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
rollback: ReturnType<typeof vi.fn>;
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface FakePool {
|
||||
poolConfig: Record<string, unknown>;
|
||||
connectionHandlers: ((conn: unknown) => void)[];
|
||||
connections: FakeConnection[];
|
||||
calls: { sql: string; values?: unknown[] }[];
|
||||
ended: boolean;
|
||||
endError: unknown;
|
||||
endThrows: unknown;
|
||||
respond: (sql: string, values?: unknown[]) => unknown;
|
||||
on: (event: string, handler: (conn: unknown) => void) => FakePool;
|
||||
promise: () => { getConnection: () => Promise<FakeConnection> };
|
||||
end: (cb: (err?: unknown) => void) => void;
|
||||
}
|
||||
|
||||
// mysql2 is the wire boundary: stub the driver's pool so we can assert on
|
||||
// the exact SQL, parameters and connection choreography the adapter emits.
|
||||
const { createPoolMock, createdPools } = vi.hoisted(() => {
|
||||
const createdPools: FakePool[] = [];
|
||||
|
||||
const createPoolMock = vi.fn((poolConfig: Record<string, unknown>) => {
|
||||
const pool: FakePool = {
|
||||
poolConfig,
|
||||
connectionHandlers: [],
|
||||
connections: [],
|
||||
calls: [],
|
||||
ended: false,
|
||||
endError: null,
|
||||
endThrows: null,
|
||||
respond: () => [[], undefined],
|
||||
on(event, handler) {
|
||||
if (event === 'connection') {
|
||||
pool.connectionHandlers.push(handler);
|
||||
}
|
||||
return pool;
|
||||
},
|
||||
promise() {
|
||||
return {
|
||||
getConnection: async () => {
|
||||
const run = async (sql: string, values?: unknown[]) => {
|
||||
pool.calls.push({ sql, values });
|
||||
return pool.respond(sql, values);
|
||||
};
|
||||
const conn: FakeConnection = {
|
||||
query: vi.fn(run),
|
||||
execute: vi.fn(run),
|
||||
beginTransaction: vi.fn(async () => {}),
|
||||
commit: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
pool.connections.push(conn);
|
||||
return conn;
|
||||
},
|
||||
};
|
||||
},
|
||||
end(cb) {
|
||||
if (pool.endThrows) throw pool.endThrows;
|
||||
pool.ended = true;
|
||||
cb(pool.endError ?? undefined);
|
||||
},
|
||||
};
|
||||
createdPools.push(pool);
|
||||
return pool;
|
||||
});
|
||||
|
||||
return { createPoolMock, createdPools };
|
||||
});
|
||||
|
||||
vi.mock('mysql2', () => ({ createPool: createPoolMock }));
|
||||
|
||||
describe('compareMigrationFilenames', () => {
|
||||
it('orders numbered migrations numerically, not lexically', () => {
|
||||
@@ -93,3 +179,669 @@ describe('compareMigrationFilenames', () => {
|
||||
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Replica read failover ───────────────────────────────────────────
|
||||
//
|
||||
// `read()` normally goes to the replica batcher; when the replica side is
|
||||
// degraded (batcher load-shed or a transient connection error) and a real
|
||||
// replica is configured, the read retries once on the primary batcher.
|
||||
|
||||
type Batcher = { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
const makeClient = (opts: {
|
||||
replica: Batcher;
|
||||
primary: Batcher;
|
||||
multiNode?: boolean;
|
||||
}) => {
|
||||
const client = new MySQLDatabaseClient({
|
||||
database: { engine: 'mysql' },
|
||||
} as IConfig);
|
||||
// Bypass onServerStart (which would connect to a real database) and
|
||||
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
|
||||
Object.assign(client as unknown as Record<string, unknown>, {
|
||||
dbReplica: opts.replica,
|
||||
db: opts.primary,
|
||||
configuration: opts.multiNode === false ? 0 : 1,
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
const codedError = (code: string) => {
|
||||
const err = new Error(code) as Error & { code: string };
|
||||
err.code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
describe('MySQLDatabaseClient.read — replica failover', () => {
|
||||
it('fails over to the primary on batcher load-shed errors', async () => {
|
||||
const replica = {
|
||||
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
|
||||
};
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
expect(primary.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails over on transient connection errors', async () => {
|
||||
const replica = {
|
||||
execute: vi.fn().mockRejectedValue(codedError('ECONNRESET')),
|
||||
};
|
||||
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
|
||||
});
|
||||
|
||||
it('rethrows deterministic SQL errors without touching the primary', async () => {
|
||||
const replica = {
|
||||
execute: vi.fn().mockRejectedValue(codedError('ER_PARSE_ERROR')),
|
||||
};
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary });
|
||||
|
||||
await expect(client.read('SELEC oops')).rejects.toMatchObject({
|
||||
code: 'ER_PARSE_ERROR',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fail over in single-node configuration', async () => {
|
||||
const replica = {
|
||||
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
|
||||
};
|
||||
const primary = { execute: vi.fn() };
|
||||
const client = makeClient({ replica, primary, multiNode: false });
|
||||
|
||||
await expect(client.read('SELECT 1')).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
});
|
||||
expect(primary.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Driver-level behaviour (stubbed mysql2 pool) ---------------------
|
||||
|
||||
const mysqlConfig = (
|
||||
database: Partial<NonNullable<IConfig['database']>> = {},
|
||||
): IConfig =>
|
||||
({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: { engine: 'mysql', ...database },
|
||||
}) as IConfig;
|
||||
|
||||
const startClient = async (
|
||||
database: Partial<NonNullable<IConfig['database']>> = {},
|
||||
): Promise<MySQLDatabaseClient> => {
|
||||
const client = new MySQLDatabaseClient(mysqlConfig(database));
|
||||
await client.onServerStart();
|
||||
return client;
|
||||
};
|
||||
|
||||
// The batcher coalesces a batch into `stmt1;stmt2; SELECT 1`, so the driver
|
||||
// must answer with one row-set per statement plus one for the SELECT 1.
|
||||
const rowSets = (...sets: unknown[][]) => [[...sets, [{ 1: 1 }]], undefined];
|
||||
|
||||
beforeEach(() => {
|
||||
createdPools.length = 0;
|
||||
createPoolMock.mockClear();
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — pool construction', () => {
|
||||
it('builds the primary pool from config with multi-statement support', async () => {
|
||||
await startClient({
|
||||
host: 'db.internal',
|
||||
port: 3307,
|
||||
user: 'puter',
|
||||
password: 'hunter2',
|
||||
database: 'puterdb',
|
||||
});
|
||||
|
||||
expect(createPoolMock).toHaveBeenCalledTimes(1);
|
||||
expect(createdPools[0].poolConfig).toEqual({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
enableKeepAlive: true,
|
||||
host: 'db.internal',
|
||||
port: 3307,
|
||||
user: 'puter',
|
||||
password: 'hunter2',
|
||||
database: 'puterdb',
|
||||
multipleStatements: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to loopback defaults when the endpoint is unspecified', async () => {
|
||||
await startClient();
|
||||
|
||||
expect(createdPools[0].poolConfig).toMatchObject({
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
user: 'root',
|
||||
password: '',
|
||||
database: 'puter',
|
||||
});
|
||||
});
|
||||
|
||||
it('arms a server-side statement timeout on every new connection', async () => {
|
||||
await startClient({ selectTimeoutMs: 12_000 });
|
||||
|
||||
const pool = createdPools[0];
|
||||
expect(pool.connectionHandlers).toHaveLength(1);
|
||||
|
||||
const conn = { query: vi.fn() };
|
||||
pool.connectionHandlers[0](conn);
|
||||
expect(conn.query).toHaveBeenCalledWith(
|
||||
'SET SESSION max_execution_time = 12000',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the statement timeout when configured to 0', async () => {
|
||||
await startClient({ selectTimeoutMs: 0 });
|
||||
expect(createdPools[0].connectionHandlers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shares one pool between reads and writes without a replica', async () => {
|
||||
await startClient();
|
||||
expect(createPoolMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates a second pool when a read-replica is configured', async () => {
|
||||
await startClient({
|
||||
replica: { host: 'replica.internal', port: 3306 },
|
||||
});
|
||||
|
||||
expect(createPoolMock).toHaveBeenCalledTimes(2);
|
||||
expect(createdPools[1].poolConfig).toMatchObject({
|
||||
host: 'replica.internal',
|
||||
multipleStatements: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — query interface', () => {
|
||||
it('sends the query and its parameters through to the driver', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () => rowSets([{ id: 1, username: 'ada' }]);
|
||||
|
||||
await expect(
|
||||
client.read('SELECT * FROM `user` WHERE `id` = ?', [1]),
|
||||
).resolves.toEqual([{ id: 1, username: 'ada' }]);
|
||||
|
||||
expect(pool.calls).toEqual([
|
||||
{
|
||||
sql: 'SELECT * FROM `user` WHERE `id` = ?; SELECT 1',
|
||||
values: [1],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the driver yields no row-set', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () => [[], undefined];
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([]);
|
||||
await expect(client.pread('SELECT 1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('reads from the replica pool and primary-reads from the primary', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, replica] = createdPools;
|
||||
primary.respond = () => rowSets([{ from: 'primary' }]);
|
||||
replica.respond = () => rowSets([{ from: 'replica' }]);
|
||||
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'replica' },
|
||||
]);
|
||||
await expect(client.pread('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'primary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps the mysql result header onto the shared write result', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () =>
|
||||
rowSets({ insertId: 42, affectedRows: 2 } as unknown as unknown[]);
|
||||
|
||||
await expect(
|
||||
client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
|
||||
'ada',
|
||||
1,
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
insertId: 42,
|
||||
affectedRows: 2,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no rows affected when the header omits the counters', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].respond = () => rowSets({} as unknown as unknown[]);
|
||||
|
||||
await expect(client.write('DELETE FROM `user`')).resolves.toEqual({
|
||||
insertId: 0,
|
||||
affectedRows: 0,
|
||||
anyRowsAffected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds an INSERT with quoted identifiers and positional params', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () =>
|
||||
rowSets({ insertId: 7, affectedRows: 1 } as unknown as unknown[]);
|
||||
|
||||
await expect(
|
||||
client.insert('user', { username: 'ada', email: null }),
|
||||
).resolves.toMatchObject({ insertId: 7 });
|
||||
|
||||
expect(pool.calls[0]).toEqual({
|
||||
sql:
|
||||
'INSERT INTO `user` (`username`, `email`) VALUES (?, ?); ' +
|
||||
'SELECT 1',
|
||||
values: ['ada', null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — batchWrite transactions', () => {
|
||||
it('runs every statement on one connection inside a transaction', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
|
||||
await client.batchWrite([
|
||||
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
|
||||
{
|
||||
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
|
||||
values: ['s'],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pool.connections).toHaveLength(1);
|
||||
const conn = pool.connections[0];
|
||||
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(conn.execute.mock.calls).toEqual([
|
||||
['UPDATE `user` SET `x` = ?', [1]],
|
||||
['DELETE FROM `sessions` WHERE `uuid` = ?', ['s']],
|
||||
]);
|
||||
expect(conn.commit).toHaveBeenCalledTimes(1);
|
||||
expect(conn.rollback).not.toHaveBeenCalled();
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rolls back and releases the connection when a statement fails', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = (sql) => {
|
||||
if (sql.startsWith('DELETE')) throw new Error('constraint blew up');
|
||||
return [[], undefined];
|
||||
};
|
||||
|
||||
await expect(
|
||||
client.batchWrite([
|
||||
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
|
||||
{ statement: 'DELETE FROM `user`', values: [] },
|
||||
]),
|
||||
).rejects.toThrow('constraint blew up');
|
||||
|
||||
const conn = pool.connections[0];
|
||||
expect(conn.commit).not.toHaveBeenCalled();
|
||||
expect(conn.rollback).toHaveBeenCalledTimes(1);
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still rethrows the original failure when the rollback also fails', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () => {
|
||||
throw new Error('statement blew up');
|
||||
};
|
||||
|
||||
const original = client.batchWrite([
|
||||
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
|
||||
]);
|
||||
// The rollback is attached after the connection is handed out, so
|
||||
// patch it once the adapter has acquired one.
|
||||
await Promise.resolve();
|
||||
pool.connections[0]?.rollback.mockRejectedValue(
|
||||
new Error('rollback blew up'),
|
||||
);
|
||||
|
||||
await expect(original).rejects.toThrow('statement blew up');
|
||||
expect(pool.connections[0].release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never acquires a connection for an empty batch', async () => {
|
||||
const client = await startClient();
|
||||
await client.batchWrite([]);
|
||||
expect(createdPools[0].connections).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — tryHardRead', () => {
|
||||
it('issues exactly one query in single-node configuration', async () => {
|
||||
const client = await startClient();
|
||||
const pool = createdPools[0];
|
||||
pool.respond = () => rowSets([{ id: 1 }]);
|
||||
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ id: 1 },
|
||||
]);
|
||||
expect(pool.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prefers replica rows when the replica has them', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, replica] = createdPools;
|
||||
primary.respond = () => rowSets([{ from: 'primary' }]);
|
||||
replica.respond = () => rowSets([{ from: 'replica' }]);
|
||||
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'replica' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the primary when the replica is behind', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, replica] = createdPools;
|
||||
primary.respond = () => rowSets([{ from: 'primary' }]);
|
||||
replica.respond = () => rowSets([]);
|
||||
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'primary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the primary when the replica query throws', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, replica] = createdPools;
|
||||
primary.respond = () => rowSets([{ from: 'primary' }]);
|
||||
replica.respond = () => {
|
||||
throw new Error('replica down');
|
||||
};
|
||||
|
||||
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'primary' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — migrations', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'puter-mysql-mig-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies mysql migrations in numeric order, one statement at a time', async () => {
|
||||
writeFileSync(
|
||||
join(dir, 'mysql_mig_2.sql'),
|
||||
'CREATE TABLE b (id INT);\nCREATE TABLE c (id INT);\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'mysql_mig_10.sql'),
|
||||
'CREATE TABLE d (id INT);',
|
||||
);
|
||||
// Other engines' files and non-SQL noise must be ignored.
|
||||
writeFileSync(join(dir, 'postgres_mig_1.sql'), 'CREATE TABLE nope ();');
|
||||
writeFileSync(join(dir, 'mysql_notes.txt'), 'not sql');
|
||||
|
||||
await startClient({ migrationPaths: [dir] });
|
||||
|
||||
const pool = createdPools[0];
|
||||
expect(pool.calls.map((c) => c.sql)).toEqual([
|
||||
'CREATE TABLE b (id INT)',
|
||||
'CREATE TABLE c (id INT)',
|
||||
'CREATE TABLE d (id INT)',
|
||||
]);
|
||||
expect(pool.connections[0].release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports which file and statement index failed', async () => {
|
||||
writeFileSync(
|
||||
join(dir, 'mysql_mig_1.sql'),
|
||||
'CREATE TABLE ok (id INT);\nCREATE TABLE bad (id INT);',
|
||||
);
|
||||
|
||||
const client = new MySQLDatabaseClient(
|
||||
mysqlConfig({ migrationPaths: [dir] }),
|
||||
);
|
||||
const cause = new Error('ER_PARSE_ERROR');
|
||||
// The pool only exists once onServerStart runs, so arm the failure
|
||||
// through the factory.
|
||||
createPoolMock.mockImplementationOnce((poolConfig) => {
|
||||
const pool = createPoolMock.getMockImplementation()!(poolConfig);
|
||||
pool.respond = (sql: string) => {
|
||||
if (sql.includes('bad')) throw cause;
|
||||
return [[], undefined];
|
||||
};
|
||||
return pool;
|
||||
});
|
||||
|
||||
await expect(client.onServerStart()).rejects.toThrow(
|
||||
'[mysql] failed to apply mysql_mig_1.sql at statement 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails loudly when a configured migration path is unreadable', async () => {
|
||||
await expect(
|
||||
startClient({ migrationPaths: [join(dir, 'does-not-exist')] }),
|
||||
).rejects.toThrow('[mysql] migration path is unreadable');
|
||||
});
|
||||
|
||||
it('skips a configured directory that holds no mysql migrations', async () => {
|
||||
writeFileSync(join(dir, 'readme.md'), '# nothing here');
|
||||
await startClient({ migrationPaths: [dir] });
|
||||
expect(createdPools[0].calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
it('replaces and closes the previous primary pool on reinit', async () => {
|
||||
const client = await startClient();
|
||||
const original = createdPools[0];
|
||||
|
||||
client.reinitPrimary();
|
||||
|
||||
expect(createdPools).toHaveLength(2);
|
||||
expect(original.ended).toBe(true);
|
||||
// Single-node: the replica batcher must follow the new primary.
|
||||
createdPools[1].respond = () => rowSets([{ from: 'new-primary' }]);
|
||||
await expect(client.read('SELECT 1')).resolves.toEqual([
|
||||
{ from: 'new-primary' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces only the replica pool on replica reinit', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
const [primary, originalReplica] = createdPools;
|
||||
|
||||
client.reinitReplica();
|
||||
|
||||
expect(createdPools).toHaveLength(3);
|
||||
expect(originalReplica.ended).toBe(true);
|
||||
expect(primary.ended).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a replica reinit when no replica is configured', async () => {
|
||||
const client = await startClient();
|
||||
client.reinitReplica();
|
||||
expect(createdPools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores reinit once shutdown has started', async () => {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
client.reinitPrimary();
|
||||
expect(createdPools).toHaveLength(1);
|
||||
await client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('closes both pools on shutdown when a replica is configured', async () => {
|
||||
const client = await startClient({ replica: { host: 'replica' } });
|
||||
await client.onServerShutdown();
|
||||
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
expect(createdPools[1].ended).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the pools once the drain window elapses', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the drain timer when shutdown arrives first', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
// A second prepare is a no-op — only one drain timer may exist.
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces a driver error raised while closing the pool', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].endError = new Error('pool refused to close');
|
||||
|
||||
await expect(client.onServerShutdown()).rejects.toThrow(
|
||||
'pool refused to close',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a driver error thrown synchronously by end()', async () => {
|
||||
const client = await startClient();
|
||||
createdPools[0].endThrows = new Error('end exploded');
|
||||
|
||||
await expect(client.onServerShutdown()).rejects.toThrow('end exploded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MySQLDatabaseClient.readWithRetry', () => {
|
||||
const codedFailure = (code: string) => {
|
||||
const err = new Error(code) as Error & { code: string };
|
||||
err.code = code;
|
||||
return err;
|
||||
};
|
||||
|
||||
it('classifies transient connection failures as retriable', () => {
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(codedFailure('ECONNRESET')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(codedFailure('ER_DUP_ENTRY')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
MySQLDatabaseClient.isRetriableError(new Error('Connection lost')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('retries a transient failure and returns the eventual result', async () => {
|
||||
const client = await startClient();
|
||||
let attempts = 0;
|
||||
const operation = vi.fn(async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) throw codedFailure('PROTOCOL_CONNECTION_LOST');
|
||||
return [{ ok: 1 }];
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).resolves.toEqual([{ ok: 1 }]);
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up after the attempt budget is spent', async () => {
|
||||
const client = await startClient();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ETIMEDOUT');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, {
|
||||
maxAttempts: 2,
|
||||
baseBackoffMs: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never retries a deterministic SQL error', async () => {
|
||||
const client = await startClient();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ER_PARSE_ERROR');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).rejects.toMatchObject({ code: 'ER_PARSE_ERROR' });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops retrying once shutdown has started', async () => {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ECONNRESET');
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
await client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('applies jittered exponential backoff capped at maxBackoffMs', async () => {
|
||||
const client = await startClient();
|
||||
const delays: number[] = [];
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
const timeoutSpy = vi
|
||||
.spyOn(globalThis, 'setTimeout')
|
||||
.mockImplementation(((fn: () => void, ms?: number) => {
|
||||
delays.push(ms ?? 0);
|
||||
return realSetTimeout(fn, 0);
|
||||
}) as typeof setTimeout);
|
||||
|
||||
try {
|
||||
const operation = vi.fn(async () => {
|
||||
throw codedFailure('ECONNRESET');
|
||||
});
|
||||
await expect(
|
||||
client.readWithRetry('health', operation, {
|
||||
maxAttempts: 4,
|
||||
baseBackoffMs: 100,
|
||||
maxBackoffMs: 150,
|
||||
jitterRatio: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'ECONNRESET' });
|
||||
expect(delays).toEqual([100, 150, 150]);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -19,31 +19,24 @@
|
||||
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { isRetriableError } from './retriableErrors.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
import { compareMigrationFilenames } from './migrationFilenames.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
const replicaFailoverCounter = metrics
|
||||
.getMeter('puter-backend')
|
||||
.createCounter('db.read.replica_failover', {
|
||||
description:
|
||||
'Reads that failed on the replica batcher and were retried on the primary',
|
||||
});
|
||||
|
||||
export { compareMigrationFilenames };
|
||||
|
||||
@@ -85,7 +78,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
@@ -96,7 +89,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
await this.runMigrations();
|
||||
}
|
||||
@@ -135,15 +128,36 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The db.* spans measure the logical query, including time queued in
|
||||
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
|
||||
// coalesced multi-statement flush, so per-query latency lives here.
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.dbReplica.execute(query, params);
|
||||
let result;
|
||||
try {
|
||||
result = await this.dbReplica.execute(query, params);
|
||||
} catch (error) {
|
||||
// Replica-side degradation (batcher load-shed or a transient
|
||||
// connection failure) shouldn't fail reads while the primary is
|
||||
// healthy. Deterministic errors (bad SQL) are rethrown — they
|
||||
// would fail identically on the primary.
|
||||
if (
|
||||
this.configuration !== Configuration.REPLICA ||
|
||||
!MySQLDatabaseClient.isFailoverWorthy(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
replicaFailoverCounter.add(1);
|
||||
result = await this.db.execute(query, params);
|
||||
}
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -153,6 +167,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -170,6 +185,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -195,6 +213,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -229,9 +248,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
/**
|
||||
* Apply `.sql` files from each configured migration directory in order.
|
||||
* Files within a directory are sorted lexically. Files MUST be
|
||||
* idempotent — there is no per-file applied-state tracking. Failures
|
||||
* abort startup so operators see schema problems loud.
|
||||
* Files within a directory are sorted lexically. Files MUST be idempotent —
|
||||
* there is no per-file applied-state tracking. Failures abort startup so
|
||||
* operators see schema problems loud.
|
||||
*/
|
||||
private async runMigrations(): Promise<void> {
|
||||
const paths = this.config.database?.migrationPaths;
|
||||
@@ -296,12 +315,53 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private createPool(poolConfig: PoolConfig): Pool {
|
||||
return createPool({
|
||||
const pool = createPool({
|
||||
maxPreparedStatements: 900,
|
||||
connectionLimit: 30,
|
||||
enableKeepAlive: true,
|
||||
...poolConfig,
|
||||
multipleStatements: true,
|
||||
} as PoolConfig);
|
||||
|
||||
// Server-side kill switch for runaway reads: MySQL applies
|
||||
// max_execution_time to SELECT statements only, so this is
|
||||
// write-safe. Without it, a stalled database turns reads into
|
||||
// indefinite hangs that no client-side timeout ever converts
|
||||
// into a failure. 0 disables.
|
||||
const selectTimeoutMs = Math.floor(
|
||||
Number(
|
||||
this.config.database?.selectTimeoutMs ??
|
||||
DEFAULT_SELECT_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
if (selectTimeoutMs > 0) {
|
||||
pool.on('connection', (conn) => {
|
||||
conn.query(
|
||||
`SET SESSION max_execution_time = ${selectTimeoutMs}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
private createPrimaryBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 30,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'primary',
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private createReplicaBatcher(pool: Pool): SQLBatcher {
|
||||
return new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 10,
|
||||
maxBatchSize: 5,
|
||||
poolLabel: 'replica',
|
||||
readOnly: true,
|
||||
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Reinitialize the primary pool (e.g. after a health-check failure). */
|
||||
@@ -317,11 +377,11 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = new SQLBatcher(this.primaryPool, 30, 5);
|
||||
this.db = this.createPrimaryBatcher(this.primaryPool);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.dbReplica = new SQLBatcher(this.primaryPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.primaryPool);
|
||||
}
|
||||
|
||||
if (previous && previous !== this.primaryPool) {
|
||||
@@ -335,7 +395,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
const previous = this.replicaPool;
|
||||
this.replicaPool = this.createPool(this.config.database.replica);
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
|
||||
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
|
||||
|
||||
if (
|
||||
previous &&
|
||||
@@ -351,11 +411,16 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static isRetriableError(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
return isRetriableError(error);
|
||||
}
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
/**
|
||||
* Replica failures worth retrying on the primary: batcher load-shed or
|
||||
* transient connection errors — never deterministic SQL errors.
|
||||
*/
|
||||
private static isFailoverWorthy(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
return code === 'dbBatchFailed' || isRetriableError(error);
|
||||
}
|
||||
|
||||
async readWithRetry(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -18,10 +18,20 @@
|
||||
*/
|
||||
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
isNeverSentError,
|
||||
isRetriableError,
|
||||
isRolledBackError,
|
||||
} from './retriableErrors.js';
|
||||
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
||||
const DEFAULT_FAILURE_THRESHOLD = 5;
|
||||
const DEFAULT_COOLDOWN_MS = 5_000;
|
||||
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000;
|
||||
const ACQUIRE_ATTEMPTS = 3;
|
||||
const ITEM_RETRY_ATTEMPTS = 2;
|
||||
const RETRY_BASE_BACKOFF_MS = 100;
|
||||
const FALLBACK_RETRY_CONCURRENCY = 8;
|
||||
|
||||
const meter = metrics.getMeter('puter-backend');
|
||||
@@ -53,6 +63,15 @@ const fallbackItemFailuresCounter = meter.createCounter(
|
||||
'Per-item failures observed during SQLBatcher per-item retry',
|
||||
},
|
||||
);
|
||||
const fallbackItemRetriesCounter = meter.createCounter(
|
||||
'sql_batcher.fallback.item_retries',
|
||||
{
|
||||
description:
|
||||
'Transient per-item failures retried during SQLBatcher fallback',
|
||||
},
|
||||
);
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class SQLBatcher {
|
||||
dbPool;
|
||||
@@ -61,18 +80,45 @@ export class SQLBatcher {
|
||||
maxQueueSize;
|
||||
failureThreshold;
|
||||
cooldownMs;
|
||||
poolLabel;
|
||||
readOnly;
|
||||
acquireTimeoutMs;
|
||||
queue = [];
|
||||
timeouts = [];
|
||||
#consecutiveFailures = 0;
|
||||
#lastFailureAt = 0;
|
||||
#metricAttrs;
|
||||
|
||||
/**
|
||||
* @param {object} dbPool Mysql2 pool
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.maxTimeInQueue] Ms an item may wait before flush
|
||||
* @param {number} [opts.maxBatchSize] Items coalesced per flush
|
||||
* @param {number} [opts.maxQueueSize] Drop-oldest high-water mark
|
||||
* @param {number} [opts.failureThreshold] Consecutive failures to open the
|
||||
* breaker
|
||||
* @param {number} [opts.cooldownMs] Breaker open duration after last
|
||||
* failure
|
||||
* @param {'primary' | 'replica'} [opts.poolLabel] Role label on metrics; in
|
||||
* single-node setups the 'replica' batcher shares the primary pool, so
|
||||
* this reflects the read/write role rather than a physical instance
|
||||
* @param {boolean} [opts.readOnly] This batcher only ever carries SELECTs,
|
||||
* so any transient failure is safe to retry
|
||||
* @param {number} [opts.acquireTimeoutMs] Max wait for a pooled connection;
|
||||
* 0 disables the bound
|
||||
*/
|
||||
constructor(
|
||||
dbPool,
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
{
|
||||
maxTimeInQueue = 20,
|
||||
maxBatchSize = 50,
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
||||
cooldownMs = DEFAULT_COOLDOWN_MS,
|
||||
poolLabel = 'primary',
|
||||
readOnly = false,
|
||||
acquireTimeoutMs = DEFAULT_ACQUIRE_TIMEOUT_MS,
|
||||
} = {},
|
||||
) {
|
||||
this.dbPool = dbPool;
|
||||
this.maxTimeInQueue = maxTimeInQueue;
|
||||
@@ -80,6 +126,10 @@ export class SQLBatcher {
|
||||
this.maxQueueSize = maxQueueSize;
|
||||
this.failureThreshold = failureThreshold;
|
||||
this.cooldownMs = cooldownMs;
|
||||
this.poolLabel = poolLabel;
|
||||
this.readOnly = readOnly;
|
||||
this.acquireTimeoutMs = acquireTimeoutMs;
|
||||
this.#metricAttrs = { pool: poolLabel };
|
||||
}
|
||||
|
||||
async execute(sql, values) {
|
||||
@@ -90,9 +140,13 @@ export class SQLBatcher {
|
||||
return this;
|
||||
}
|
||||
|
||||
#createPublicBatchError() {
|
||||
// The public error is deliberately opaque (no SQL, no internals), but
|
||||
// `reason` distinguishes the load-shed path for logs and callers:
|
||||
// breakerOpen | queueOverflow | connAcquire.
|
||||
#createPublicBatchError(reason) {
|
||||
const error = new Error('Database operation failed');
|
||||
error.code = 'dbBatchFailed';
|
||||
error.reason = reason;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -106,8 +160,8 @@ export class SQLBatcher {
|
||||
|
||||
async query(sql, values) {
|
||||
if (this.#isBreakerOpen()) {
|
||||
enqueueRejectedCounter.add(1);
|
||||
throw this.#createPublicBatchError();
|
||||
enqueueRejectedCounter.add(1, this.#metricAttrs);
|
||||
throw this.#createPublicBatchError('breakerOpen');
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
@@ -117,8 +171,8 @@ export class SQLBatcher {
|
||||
// to have already exceeded any caller-side timeout anyway.
|
||||
while (this.queue.length >= this.maxQueueSize) {
|
||||
const dropped = this.queue.shift();
|
||||
dropped.reject(this.#createPublicBatchError());
|
||||
enqueueDroppedCounter.add(1);
|
||||
dropped.reject(this.#createPublicBatchError('queueOverflow'));
|
||||
enqueueDroppedCounter.add(1, this.#metricAttrs);
|
||||
}
|
||||
|
||||
this.queue.push({
|
||||
@@ -142,6 +196,61 @@ export class SQLBatcher {
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Bounded wait for a pooled connection. Without a bound, a stalled
|
||||
// database turns every flush into an indefinite hang — nothing fails,
|
||||
// so neither the breaker nor callers' own timeouts ever engage.
|
||||
#getConnectionWithTimeout() {
|
||||
const acquire = this.dbPool.promise().getConnection();
|
||||
if (!this.acquireTimeoutMs) return acquire;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
// A connection that arrives late must go back to the pool.
|
||||
acquire.then(
|
||||
(conn) => conn.release(),
|
||||
() => {},
|
||||
);
|
||||
const error = new Error(
|
||||
'Timed out acquiring database connection',
|
||||
);
|
||||
error.code = POOL_ACQUIRE_TIMEOUT;
|
||||
reject(error);
|
||||
}, this.acquireTimeoutMs);
|
||||
|
||||
acquire.then(
|
||||
(conn) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
resolve(conn);
|
||||
},
|
||||
(err) => {
|
||||
if (timedOut) return;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Acquisition failures never sent a statement, so retrying is always
|
||||
// safe regardless of what the batch contains.
|
||||
async #acquireConnection() {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await this.#getConnectionWithTimeout();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < ACQUIRE_ATTEMPTS) {
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async flush(batch) {
|
||||
const timeout = this.timeouts.shift();
|
||||
if (timeout && !timeout._destroyed) {
|
||||
@@ -154,17 +263,17 @@ export class SQLBatcher {
|
||||
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.dbPool.promise().getConnection();
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
this.#consecutiveFailures++;
|
||||
this.#lastFailureAt = Date.now();
|
||||
flushFailureCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
console.warn(
|
||||
'SQLBatcher could not acquire connection for flush:',
|
||||
error,
|
||||
);
|
||||
for (const b of batch) {
|
||||
b.reject(this.#createPublicBatchError());
|
||||
b.reject(this.#createPublicBatchError('connAcquire'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -206,8 +315,8 @@ export class SQLBatcher {
|
||||
// committed; re-running each item independently produces clean
|
||||
// success/failure outcomes for each caller. Concurrency is capped to
|
||||
// avoid briefly saturating the pool when a large batch fails.
|
||||
flushFailureCounter.add(1);
|
||||
fallbackInvocationsCounter.add(1);
|
||||
flushFailureCounter.add(1, this.#metricAttrs);
|
||||
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
||||
|
||||
const settled = new Array(batch.length);
|
||||
let cursor = 0;
|
||||
@@ -216,17 +325,7 @@ export class SQLBatcher {
|
||||
async () => {
|
||||
while (cursor < batch.length) {
|
||||
const i = cursor++;
|
||||
const b = batch[i];
|
||||
try {
|
||||
settled[i] = {
|
||||
ok: true,
|
||||
value: await this.dbPool
|
||||
.promise()
|
||||
.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
settled[i] = { ok: false, error };
|
||||
}
|
||||
settled[i] = await this.#runFallbackItem(batch[i]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -246,7 +345,7 @@ export class SQLBatcher {
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
fallbackItemFailuresCounter.add(failureCount);
|
||||
fallbackItemFailuresCounter.add(failureCount, this.#metricAttrs);
|
||||
}
|
||||
|
||||
// Only escalate the breaker when the database itself looks unhealthy
|
||||
@@ -259,4 +358,45 @@ export class SQLBatcher {
|
||||
this.#consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Run one fallback item, retrying transient failures with backoff.
|
||||
// A read-only batcher may retry anything transient; a batcher that
|
||||
// carries writes only retries failures where the statement provably
|
||||
// did not apply — either it never reached the server, or the server
|
||||
// rolled it back itself. A write that died mid-flight may have
|
||||
// committed, and re-running that one would double-apply.
|
||||
//
|
||||
// Lock contention lands in the second group and is worth retrying rather
|
||||
// than surfacing: an item is a single statement, so a deadlock victim has
|
||||
// been fully undone, and the caller sees an unhandled 500 for what the
|
||||
// database is telling us to just run again.
|
||||
async #runFallbackItem(b) {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
let connection;
|
||||
try {
|
||||
connection = await this.#acquireConnection();
|
||||
} catch (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: await connection.query(b.sql, b.values ?? []),
|
||||
};
|
||||
} catch (error) {
|
||||
const canRetry = this.readOnly
|
||||
? isRetriableError(error) || isRolledBackError(error)
|
||||
: isNeverSentError(error) || isRolledBackError(error);
|
||||
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
attempt++;
|
||||
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
||||
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
|
||||
const makeError = (code: string, message = code): Error & { code: string } => {
|
||||
const error = new Error(message) as Error & { code: string };
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
interface FakeConnection {
|
||||
beginTransaction: ReturnType<typeof vi.fn>;
|
||||
query: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
rollback: ReturnType<typeof vi.fn>;
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
// The batch flush sends one coalesced multi-statement (always suffixed
|
||||
// `; SELECT 1`); fallback items send their original single statement.
|
||||
const isBatchQuery = (sql: string) => sql.endsWith('; SELECT 1');
|
||||
|
||||
const makeConnection = (
|
||||
onQuery: (sql: string, values: unknown[]) => unknown,
|
||||
): FakeConnection => ({
|
||||
beginTransaction: vi.fn(async () => {}),
|
||||
query: vi.fn(async (sql: string, values: unknown[]) => onQuery(sql, values)),
|
||||
commit: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
release: vi.fn(),
|
||||
});
|
||||
|
||||
const makePool = (connection: FakeConnection | (() => Promise<FakeConnection>)) => {
|
||||
const getConnection = vi.fn(async () =>
|
||||
typeof connection === 'function' ? connection() : connection,
|
||||
);
|
||||
return {
|
||||
pool: { promise: () => ({ getConnection }) },
|
||||
getConnection,
|
||||
};
|
||||
};
|
||||
|
||||
// Happy-path onQuery: batch returns one result row-set per statement plus
|
||||
// the trailing SELECT 1 row-set.
|
||||
const happyBatch = (sql: string) => {
|
||||
if (!isBatchQuery(sql)) throw new Error('unexpected fallback query');
|
||||
const statements = sql.split(';').length - 1;
|
||||
return [
|
||||
Array.from({ length: statements + 1 }, (_, i) => [{ n: i }]),
|
||||
undefined,
|
||||
];
|
||||
};
|
||||
|
||||
describe('SQLBatcher', () => {
|
||||
it('resolves each batched item with its own result', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
batcher.query('SELECT a', []),
|
||||
batcher.query('SELECT b', []),
|
||||
]);
|
||||
expect(a[0]).toEqual([{ n: 0 }]);
|
||||
expect(b[0]).toEqual([{ n: 1 }]);
|
||||
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(conn.commit).toHaveBeenCalledTimes(1);
|
||||
expect(conn.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
|
||||
const conn = makeConnection(happyBatch);
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
maxQueueSize: 1,
|
||||
});
|
||||
|
||||
const first = batcher.query('SELECT a', []);
|
||||
const second = batcher.query('SELECT b', []);
|
||||
|
||||
await expect(first).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'queueOverflow',
|
||||
});
|
||||
await expect(second).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects with reason connAcquire after exhausting acquisition retries', async () => {
|
||||
const getConnection = vi.fn(async () => {
|
||||
throw makeError('ECONNREFUSED');
|
||||
});
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('bounds connection acquisition and rejects when the pool never answers', async () => {
|
||||
const getConnection = vi.fn(
|
||||
() => new Promise(() => {}), // pool never yields a connection
|
||||
);
|
||||
const pool = { promise: () => ({ getConnection }) };
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
acquireTimeoutMs: 30,
|
||||
});
|
||||
|
||||
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'connAcquire',
|
||||
});
|
||||
expect(getConnection).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('opens the breaker after consecutive failures and rejects with reason breakerOpen', async () => {
|
||||
// Batch and fallback both fail with an ambiguous (non-retriable for
|
||||
// writes) connection error, so no item gets through.
|
||||
const conn = makeConnection(() => {
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
await expect(batcher.query('INSERT y', [])).rejects.toMatchObject({
|
||||
code: 'dbBatchFailed',
|
||||
reason: 'breakerOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries transient fallback failures when readOnly', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNRESET');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const result = await batcher.query('SELECT a', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('does not retry ambiguous failures on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
throw makeError('ECONNRESET');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
|
||||
code: 'ECONNRESET',
|
||||
});
|
||||
expect(fallbackAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('retries never-sent failures even on a batcher that carries writes', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ECONNREFUSED');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
const result = await batcher.query('INSERT x', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('retries a deadlocked write instead of surfacing it', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
|
||||
fallbackAttempts++;
|
||||
if (fallbackAttempts === 1) throw makeError('ER_LOCK_DEADLOCK');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
// The victim statement was rolled back by the server, so re-running it
|
||||
// can't double-apply — the caller should never see the deadlock.
|
||||
const result = await batcher.query('UPDATE notification SET x', []);
|
||||
expect(result[0]).toEqual([{ ok: 1 }]);
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it('gives up on a write that deadlocks past the retry budget', async () => {
|
||||
const conn = makeConnection(() => {
|
||||
throw makeError('ER_LOCK_DEADLOCK');
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
|
||||
|
||||
await expect(batcher.query('UPDATE hot SET x', [])).rejects.toMatchObject(
|
||||
{ code: 'ER_LOCK_DEADLOCK' },
|
||||
);
|
||||
});
|
||||
|
||||
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
|
||||
let fallbackAttempts = 0;
|
||||
const conn = makeConnection((sql) => {
|
||||
if (isBatchQuery(sql)) throw makeError('ER_DUP_ENTRY');
|
||||
fallbackAttempts++;
|
||||
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
|
||||
return [[{ ok: 1 }], undefined];
|
||||
});
|
||||
const { pool } = makePool(conn);
|
||||
const batcher = new SQLBatcher(pool, {
|
||||
maxTimeInQueue: 5,
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 60_000,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
const dup = batcher.query('INSERT dup', []);
|
||||
const fine = batcher.query('INSERT fine', []);
|
||||
await expect(dup).rejects.toMatchObject({ code: 'ER_DUP_ENTRY' });
|
||||
await expect(fine).resolves.toBeTruthy();
|
||||
expect(fallbackAttempts).toBe(2);
|
||||
|
||||
// One fallback item succeeded, so the breaker must stay closed.
|
||||
const conn2 = makeConnection(happyBatch);
|
||||
// reuse same batcher/pool: next query must not be rejected upfront
|
||||
conn.query.mockImplementation(conn2.query.getMockImplementation()!);
|
||||
await expect(batcher.query('SELECT a', [])).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 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 Database from 'better-sqlite3';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 61;
|
||||
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
|
||||
const sqliteConfig = (
|
||||
database: Partial<NonNullable<IConfig['database']>> = {},
|
||||
): IConfig =>
|
||||
({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
database: { engine: 'sqlite', inMemory: true, ...database },
|
||||
}) as IConfig;
|
||||
|
||||
const bootClient = async (
|
||||
database: Partial<NonNullable<IConfig['database']>> = {},
|
||||
): Promise<SqliteDatabaseClient> => {
|
||||
const client = new SqliteDatabaseClient(sqliteConfig(database));
|
||||
await client.onServerStart();
|
||||
return client;
|
||||
};
|
||||
|
||||
const userVersionOf = async (client: SqliteDatabaseClient): Promise<number> => {
|
||||
const [row] = await client.read('PRAGMA user_version');
|
||||
return row.user_version as number;
|
||||
};
|
||||
|
||||
describe('SqliteDatabaseClient — boot and migrations', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = await bootClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('is what the factory picks for the sqlite engine', () => {
|
||||
expect(new DatabaseClientFactory(sqliteConfig())).toBeInstanceOf(
|
||||
SqliteDatabaseClient,
|
||||
);
|
||||
});
|
||||
|
||||
it('migrates a fresh database all the way to the current version', async () => {
|
||||
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
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.
|
||||
const rows = await client.read(
|
||||
'SELECT `username` FROM `user` WHERE `uuid` = ?',
|
||||
[SYSTEM_USER_UUID],
|
||||
);
|
||||
expect(rows).toEqual([{ username: 'system' }]);
|
||||
});
|
||||
|
||||
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');
|
||||
try {
|
||||
const first = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
await first.onServerStart();
|
||||
await first.write(
|
||||
'INSERT INTO `kv` (`user_id`, `kkey_hash`, `kkey`, `value`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[1, 1, 'boot-marker', '"kept"'],
|
||||
);
|
||||
first.onServerShutdown();
|
||||
|
||||
expect(existsSync(path)).toBe(true);
|
||||
|
||||
const second = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
await second.onServerStart();
|
||||
await expect(
|
||||
second.read('SELECT `value` FROM `kv` WHERE `kkey` = ?', [
|
||||
'boot-marker',
|
||||
]),
|
||||
).resolves.toEqual([{ value: '"kept"' }]);
|
||||
expect(await userVersionOf(second)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
second.onServerShutdown();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('stops early at a configured target version', async () => {
|
||||
const partial = await bootClient({ targetVersion: 5 });
|
||||
try {
|
||||
expect(await userVersionOf(partial)).toBe(5);
|
||||
// 0005 landed (apps.background); 0011 (notification) did not.
|
||||
await expect(
|
||||
partial.read(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'notification'",
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
partial.read(
|
||||
"SELECT 1 FROM pragma_table_info('apps') WHERE name = 'background'",
|
||||
),
|
||||
).resolves.toHaveLength(1);
|
||||
} finally {
|
||||
partial.onServerShutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SqliteDatabaseClient — legacy version inference', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-legacy-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('treats user_version=0 with no bootstrap tables as uninitialized', async () => {
|
||||
const path = join(dir, 'blank.sqlite');
|
||||
new Database(path).close();
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path }),
|
||||
);
|
||||
try {
|
||||
await client.onServerStart();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('bootstrap tables are missing'),
|
||||
);
|
||||
// A full migration run followed, so the schema is current.
|
||||
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
} finally {
|
||||
client.onServerShutdown();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('infers the schema version from table and column markers', async () => {
|
||||
const path = join(dir, 'legacy.sqlite');
|
||||
const seed = new Database(path);
|
||||
seed.exec(`
|
||||
CREATE TABLE user (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
otp_secret TEXT,
|
||||
otp_enabled INTEGER,
|
||||
otp_recovery_codes TEXT
|
||||
);
|
||||
CREATE TABLE apps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uid TEXT,
|
||||
background INTEGER,
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE TABLE user_to_user_permissions (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE audit_user_to_user_permissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
issuer_user_id INTEGER,
|
||||
holder_user_id INTEGER
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_at INTEGER,
|
||||
last_activity INTEGER
|
||||
);
|
||||
CREATE TABLE kv (id INTEGER PRIMARY KEY, value JSON);
|
||||
INSERT INTO apps (uid)
|
||||
VALUES ('app-e3ac5486-da8c-42ad-8377-8728086e0980');
|
||||
`);
|
||||
seed.close();
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
// targetVersion just past the inferred version keeps the run a
|
||||
// no-op, so the assertion is about inference alone.
|
||||
const client = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path, targetVersion: 22 }),
|
||||
);
|
||||
try {
|
||||
await client.onServerStart();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[sqlite] user_version=0; inferred legacy schema version 21',
|
||||
);
|
||||
// Nothing was applied, so user_version stays where it was.
|
||||
expect(await userVersionOf(client)).toBe(0);
|
||||
} finally {
|
||||
client.onServerShutdown();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports version 0 when only the bootstrap tables exist', async () => {
|
||||
const path = join(dir, 'bootstrap-only.sqlite');
|
||||
const seed = new Database(path);
|
||||
seed.exec(`
|
||||
CREATE TABLE user (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE apps (id INTEGER PRIMARY KEY);
|
||||
`);
|
||||
seed.close();
|
||||
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const client = new SqliteDatabaseClient(
|
||||
sqliteConfig({ inMemory: false, path, targetVersion: 1 }),
|
||||
);
|
||||
try {
|
||||
await client.onServerStart();
|
||||
expect(log).toHaveBeenCalledWith('[sqlite] database version: 0');
|
||||
} finally {
|
||||
client.onServerShutdown();
|
||||
log.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SqliteDatabaseClient — query interface', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = await bootClient();
|
||||
await client.write(
|
||||
'CREATE TABLE `widget` (`id` INTEGER PRIMARY KEY, ' +
|
||||
'`name` TEXT, `enabled` INTEGER, `seen_at` TEXT)',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('reports lastInsertRowid and change counts on write', async () => {
|
||||
const inserted = await client.write(
|
||||
'INSERT INTO `widget` (`name`) VALUES (?)',
|
||||
['spanner'],
|
||||
);
|
||||
expect(inserted).toEqual({
|
||||
insertId: 1,
|
||||
affectedRows: 1,
|
||||
anyRowsAffected: true,
|
||||
});
|
||||
|
||||
const missed = await client.write(
|
||||
'UPDATE `widget` SET `name` = ? WHERE `id` = ?',
|
||||
['nope', 999],
|
||||
);
|
||||
expect(missed).toMatchObject({
|
||||
affectedRows: 0,
|
||||
anyRowsAffected: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('binds booleans as sqlite integers', async () => {
|
||||
await client.write(
|
||||
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
|
||||
['toggled', true],
|
||||
);
|
||||
await client.write(
|
||||
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
|
||||
['untoggled', false],
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.read('SELECT `name`, `enabled` FROM `widget` ORDER BY `id`'),
|
||||
).resolves.toEqual([
|
||||
{ name: 'toggled', enabled: 1 },
|
||||
{ name: 'untoggled', enabled: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rewrites now() into the sqlite equivalent', async () => {
|
||||
await client.write(
|
||||
'INSERT INTO `widget` (`name`, `seen_at`) VALUES (?, NOW())',
|
||||
['stamped'],
|
||||
);
|
||||
const [row] = await client.read(
|
||||
'SELECT `seen_at` FROM `widget` WHERE `name` = ?',
|
||||
['stamped'],
|
||||
);
|
||||
expect(row.seen_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it('generates an INSERT from a data object', async () => {
|
||||
await client.insert('widget', { name: 'generated', enabled: true });
|
||||
await expect(
|
||||
client.read('SELECT `name`, `enabled` FROM `widget`'),
|
||||
).resolves.toEqual([{ name: 'generated', enabled: 1 }]);
|
||||
});
|
||||
|
||||
it('primary-reads through the same single-node connection', async () => {
|
||||
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['p']);
|
||||
await expect(
|
||||
client.pread('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([{ name: 'p' }]);
|
||||
});
|
||||
|
||||
it('applies a batch write atomically', async () => {
|
||||
await client.batchWrite([
|
||||
{
|
||||
statement:
|
||||
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
|
||||
values: ['one', true],
|
||||
},
|
||||
{
|
||||
statement: 'INSERT INTO `widget` (`name`) VALUES (?)',
|
||||
values: ['two'],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
client.read('SELECT `name` FROM `widget` ORDER BY `id`'),
|
||||
).resolves.toEqual([{ name: 'one' }, { name: 'two' }]);
|
||||
});
|
||||
|
||||
it('rolls the whole batch back when one statement fails', async () => {
|
||||
await expect(
|
||||
client.batchWrite([
|
||||
{
|
||||
statement: 'INSERT INTO `widget` (`name`) VALUES (?)',
|
||||
values: ['kept-if-broken'],
|
||||
},
|
||||
{
|
||||
statement: 'INSERT INTO `nonexistent` (`name`) VALUES (?)',
|
||||
values: ['boom'],
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow(/no such table/i);
|
||||
|
||||
await expect(
|
||||
client.read('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('surfaces sqlite errors for malformed SQL', async () => {
|
||||
await expect(client.read('SELEC oops')).rejects.toThrow(
|
||||
/syntax error/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the database on shutdown', async () => {
|
||||
const closable = await bootClient();
|
||||
closable.onServerShutdown();
|
||||
await expect(closable.read('SELECT 1')).rejects.toThrow(
|
||||
'The database connection is not open',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Single-node engines have no replica to race, so `tryHardRead` must not
|
||||
// issue the query twice — the base-class default fires `pread()` and
|
||||
// `read()` in parallel, which on sqlite is the same connection.
|
||||
describe('SqliteDatabaseClient — tryHardRead', () => {
|
||||
let client: SqliteDatabaseClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
client = await bootClient();
|
||||
await client.write(
|
||||
'CREATE TABLE `widget` (`id` INTEGER PRIMARY KEY, `name` TEXT)',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.onServerShutdown();
|
||||
});
|
||||
|
||||
it('executes the statement exactly once', async () => {
|
||||
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['one']);
|
||||
const readSpy = vi.spyOn(client, 'read');
|
||||
const preadSpy = vi.spyOn(client, 'pread');
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([{ name: 'one' }]);
|
||||
|
||||
expect(readSpy).toHaveBeenCalledTimes(1);
|
||||
expect(preadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes the statement exactly once when it matches no rows', async () => {
|
||||
const readSpy = vi.spyOn(client, 'read');
|
||||
const preadSpy = vi.spyOn(client, 'pread');
|
||||
|
||||
await expect(
|
||||
client.tryHardRead('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(readSpy).toHaveBeenCalledTimes(1);
|
||||
expect(preadSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws from requireRead when nothing matches', async () => {
|
||||
await expect(
|
||||
client.requireRead(
|
||||
'SELECT `name` FROM `widget` WHERE `id` = ?',
|
||||
[42],
|
||||
),
|
||||
).rejects.toThrow('required read returned no rows');
|
||||
});
|
||||
|
||||
it('returns the rows from requireRead when something matches', async () => {
|
||||
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['ok']);
|
||||
await expect(
|
||||
client.requireRead('SELECT `name` FROM `widget`'),
|
||||
).resolves.toEqual([{ name: 'ok' }]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -21,14 +21,15 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import type { IConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
|
||||
/**
|
||||
* Ordered list of [threshold_version, files[]] pairs.
|
||||
* A database whose `user_version` is <= threshold_version will have
|
||||
* the corresponding files applied.
|
||||
* Ordered list of [threshold_version, files[]] pairs. A database whose
|
||||
* `user_version` is <= threshold_version will have the corresponding files
|
||||
* applied.
|
||||
*/
|
||||
const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[-1, ['0001_create-tables.sql', '0002_add-default-apps.sql']],
|
||||
@@ -85,6 +86,15 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[50, ['0054_sessions_workers.sql']],
|
||||
[50, ['0055_username_nocase_unique.sql']],
|
||||
[51, ['0056_sessions_kind_worker.sql']],
|
||||
[52, ['0057_add_user_reputation.sql']],
|
||||
[53, ['0058_add_phone_verification.sql']],
|
||||
[54, ['0059_add_card_verification.sql']],
|
||||
[55, ['0060_add_card_fingerprint.sql']],
|
||||
[56, ['0061_add_suspended_at.sql']],
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
[60, ['0065_app-feedback.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -128,6 +138,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -148,6 +159,17 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
// No replica to race, so the base class's parallel
|
||||
// primary-plus-replica read would run the same statement twice on
|
||||
// the one connection.
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -164,6 +186,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -33,9 +33,9 @@ import { PostgresDatabaseClient } from './PostgresDatabaseClient';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient';
|
||||
|
||||
/**
|
||||
* Factory class registered in `puterClients`. PuterServer calls
|
||||
* `new DatabaseClientFactory(config)` — the constructor returns the
|
||||
* concrete subclass selected by `config.database.engine`.
|
||||
* Factory class registered in `puterClients`. PuterServer calls `new
|
||||
* DatabaseClientFactory(config)` — the constructor returns the concrete
|
||||
* subclass selected by `config.database.engine`.
|
||||
*/
|
||||
export const DatabaseClientFactory = class DatabaseClientFactory {
|
||||
constructor(config: IConfig) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
--
|
||||
|
||||
--
|
||||
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
|
||||
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
|
||||
-- migration files, which CALL it as a one-line idempotent column add. Left
|
||||
-- resident (not dropped at end of file) so it outlives this migration.
|
||||
--
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
|
||||
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
|
||||
-- it as a one-line idempotent column add, and migrations replay on every boot
|
||||
-- with no applied-state tracking, so the helper must outlive this file. The
|
||||
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
|
||||
@@ -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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
--
|
||||
-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it
|
||||
-- resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_phone_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_phone'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_phone_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
|
||||
-- directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
@@ -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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_card_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_card_fingerprint_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_suspended_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_suspended_at'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_suspended_at_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
|
||||
-- An app whose `index_url` host (or a request origin) matches an entry is
|
||||
-- denied access to Puter resources: it cannot obtain an app token and
|
||||
-- already-issued app tokens are rejected on each request. `include_subdomains
|
||||
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
|
||||
-- AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`domain` VARCHAR(255) NOT NULL,
|
||||
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`created_by` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
|
||||
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
|
||||
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
|
||||
-- gives the denominator. Preserves history the `user` suspension columns lose
|
||||
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`target_user_id` BIGINT DEFAULT NULL,
|
||||
`target_username` VARCHAR(255) DEFAULT NULL,
|
||||
`admin_username` VARCHAR(255) DEFAULT NULL,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`source` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
|
||||
KEY `idx_abuse_moderation_events_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065. Opt-in is the new `apps.feedback_enabled` column (developer-writable
|
||||
-- through the regular `puter.apps.update` path). Each row of `app_feedback`
|
||||
-- is one message a signed-in user submitted through the GUI feedback dialog;
|
||||
-- a copy is emailed to the app owner unless the per-app daily email cap
|
||||
-- suppressed it (`email_sent` records which). `app_uid` is denormalized
|
||||
-- alongside `app_id` so rows stay attributable after an app is deleted.
|
||||
-- `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- always runs first) and the table uses `CREATE TABLE IF NOT EXISTS`, so the
|
||||
-- directory can replay safely.
|
||||
|
||||
CALL _puter_add_col('apps', 'feedback_enabled', '`feedback_enabled` tinyint(1) DEFAULT ''0''');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`uid` CHAR(36) NOT NULL,
|
||||
`app_id` INT NOT NULL,
|
||||
`app_uid` CHAR(40) NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`source_env` VARCHAR(16) DEFAULT NULL,
|
||||
`source_origin` VARCHAR(2048) DEFAULT NULL,
|
||||
`email_sent` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_app_feedback_uid` (`uid`),
|
||||
KEY `idx_app_feedback_app_created` (`app_id`, `created_at`),
|
||||
KEY `idx_app_feedback_user_created` (`user_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
|
||||
@@ -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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
|
||||
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
|
||||
-- `index_url` host (or a request origin) matches an entry is denied access to
|
||||
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_app_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
include_subdomains SMALLINT NOT NULL DEFAULT 0,
|
||||
reason TEXT DEFAULT NULL,
|
||||
created_by VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
|
||||
ON blocked_app_origins (domain);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
|
||||
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
|
||||
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
|
||||
-- signals; `suspend` gives the denominator. Preserves history the `user`
|
||||
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT DEFAULT NULL,
|
||||
target_username VARCHAR(255) DEFAULT NULL,
|
||||
admin_username VARCHAR(255) DEFAULT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
source VARCHAR(64) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON abuse_moderation_events (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON abuse_moderation_events (action);
|
||||
@@ -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/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Mirrors SQLite migration
|
||||
-- 0065 / MySQL mysql_mig_20. Opt-in is the new `apps.feedback_enabled` column
|
||||
-- (developer-writable through the regular `puter.apps.update` path). Each row
|
||||
-- of `app_feedback` is one message a signed-in user submitted through the GUI
|
||||
-- feedback dialog; a copy is emailed to the app owner unless the per-app
|
||||
-- daily email cap suppressed it (`email_sent` records which). `app_uid` is
|
||||
-- denormalized alongside `app_id` so rows stay attributable after an app is
|
||||
-- deleted. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN IF NOT EXISTS feedback_enabled boolean NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_feedback (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
uid CHAR(36) NOT NULL UNIQUE,
|
||||
app_id BIGINT NOT NULL,
|
||||
app_uid VARCHAR(40) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_env VARCHAR(16) DEFAULT NULL,
|
||||
source_origin VARCHAR(2048) DEFAULT NULL,
|
||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON app_feedback (app_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON app_feedback (user_id, created_at);
|
||||
@@ -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/>.
|
||||
|
||||
-- Abuse v2 reputation score (0–100+) recorded at signup. Mirrors the
|
||||
-- `reputation smallint DEFAULT 100` column already present in the mysql and
|
||||
-- postgres baseline migrations.
|
||||
ALTER TABLE `user` ADD COLUMN `reputation` SMALLINT DEFAULT 100;
|
||||
@@ -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/>.
|
||||
|
||||
-- SMS phone verification. `phone` holds the E.164 number collected during
|
||||
-- verification (indexed like `email`). `requires_phone_verification` gates
|
||||
-- account use until verified — the abuse v2 harness sets it for low-reputation
|
||||
-- signups instead of blocking them. (Not indexed, mirroring
|
||||
-- `requires_email_confirmation`.)
|
||||
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
|
||||
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Credit-card verification ($0 authorization handled by a payments
|
||||
-- extension). `requires_card_verification` gates account use until verified —
|
||||
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
|
||||
-- them. The card itself never touches our DB, so this is the only column.
|
||||
-- (Not indexed, mirroring `requires_phone_verification`.)
|
||||
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
@@ -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/>.
|
||||
|
||||
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
|
||||
-- (stable per card number) recorded when a user clears card verification — the
|
||||
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
|
||||
-- accounts that verified with a given card. The card itself never touches our
|
||||
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
|
||||
-- allowlist), so it never reaches clients.
|
||||
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
|
||||
@@ -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/>.
|
||||
|
||||
-- When the account was suspended, as unix seconds; NULL while not suspended.
|
||||
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
|
||||
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
|
||||
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
|
||||
-- a request origin) matches an entry is denied access to Puter resources:
|
||||
-- it cannot obtain an app token and already-issued app tokens are rejected
|
||||
-- on each request. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"domain" TEXT NOT NULL,
|
||||
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_by" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
|
||||
ON `blocked_app_origins` (`domain`);
|
||||
@@ -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/>.
|
||||
|
||||
-- Why an account was suspended, NULL while not suspended. Companion to the
|
||||
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
|
||||
-- application layer to a fixed set of reasons (see extensions/admin
|
||||
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
|
||||
-- needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
|
||||
@@ -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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions, used to measure the abuse
|
||||
-- system's false-positive rate: `unsuspend` (admin unblock) and
|
||||
-- `admin_create_user` (an account minted by an admin, typically to recover a
|
||||
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
|
||||
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
|
||||
-- columns on `user` — which are cleared on unsuspend and so keep no history —
|
||||
-- this table preserves every event. `created_at` is unix seconds (matching
|
||||
-- `user.suspended_at`), stamped by the recorder in
|
||||
-- extensions/admin/moderation_events.js. Written best-effort at the admin
|
||||
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
|
||||
"target_user_id" INTEGER DEFAULT NULL,
|
||||
"target_username" TEXT DEFAULT NULL,
|
||||
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON `abuse_moderation_events` (`created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON `abuse_moderation_events` (`action`);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- User-to-developer feedback for apps that opt in. Opt-in is the new
|
||||
-- `apps.feedback_enabled` column (developer-writable through the regular
|
||||
-- `puter.apps.update` path; a dedicated column rather than a `metadata` key
|
||||
-- because Dev Center saves replace the whole metadata blob and would erase
|
||||
-- it). Each row of `app_feedback` is one message a signed-in user submitted
|
||||
-- through the GUI feedback dialog; a copy is emailed to the app owner unless
|
||||
-- the per-app daily email cap suppressed it (`email_sent` records which).
|
||||
-- `app_uid` is denormalized alongside `app_id` so rows stay attributable
|
||||
-- after an app is deleted (abuse forensics). `source_env` is 'app' (desktop
|
||||
-- dialog) or 'web' (puter.com popup opened from an external site);
|
||||
-- `source_origin` is the popup opener's browser-attested origin, null for
|
||||
-- desktop submissions. `created_at` is unix seconds. The (user_id,
|
||||
-- created_at) and (app_id, created_at) indexes serve the sliding-window
|
||||
-- rate-limit counts in AppFeedbackStore.
|
||||
|
||||
ALTER TABLE apps ADD COLUMN "feedback_enabled" tinyint(1) DEFAULT '0';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `app_feedback` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"uid" TEXT NOT NULL UNIQUE,
|
||||
"app_id" INTEGER NOT NULL,
|
||||
"app_uid" TEXT NOT NULL,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"source_env" TEXT DEFAULT NULL, -- 'app' | 'web'
|
||||
"source_origin" TEXT DEFAULT NULL, -- attested opener origin (web popups)
|
||||
"email_sent" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_app_created
|
||||
ON `app_feedback` (`app_id`, `created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_feedback_user_created
|
||||
ON `app_feedback` (`user_id`, `created_at`);
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/** Error code set by the pool-acquisition timeout in SQLBatcher. */
|
||||
export const POOL_ACQUIRE_TIMEOUT = 'POOL_ACQUIRE_TIMEOUT';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Failures where the statement provably never reached the server, so a retry
|
||||
* can never double-apply it — safe even for writes. Anything that can occur
|
||||
* after the statement was sent (resets, protocol drops) is deliberately
|
||||
* excluded: the server may have committed before the connection died.
|
||||
*/
|
||||
const NEVER_SENT_ERROR_CODES = new Set([
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
POOL_ACQUIRE_TIMEOUT,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Failures the server itself rolled back before returning. The statement did
|
||||
* reach the database, so this is not `NEVER_SENT`, but InnoDB guarantees it
|
||||
* left no effect — which makes a retry just as safe for writes.
|
||||
*
|
||||
* Only true for a _single_ statement: under autocommit each statement is its
|
||||
* own transaction, so "the transaction was rolled back" means "this statement
|
||||
* was rolled back". Retrying a multi-statement string on one of these would
|
||||
* re-run the statements that already committed ahead of the failure.
|
||||
*/
|
||||
const ROLLED_BACK_ERROR_CODES = new Set([
|
||||
// Deadlock — InnoDB picked this transaction as the victim and undid it.
|
||||
// MySQL's own message for it is "try restarting transaction".
|
||||
'ER_LOCK_DEADLOCK',
|
||||
// Lock wait timeout. Rolls back the statement rather than the transaction
|
||||
// unless innodb_rollback_on_timeout is set — the same thing when the
|
||||
// transaction is one statement.
|
||||
'ER_LOCK_WAIT_TIMEOUT',
|
||||
]);
|
||||
|
||||
const errorCode = (error: unknown): string | undefined =>
|
||||
(error as { code?: string } | null)?.code;
|
||||
|
||||
/**
|
||||
* Transient connection-level failures worth retrying — but only for statements
|
||||
* that are safe to run twice (reads). Row-level errors (duplicate key,
|
||||
* constraint violations) are deterministic and never match.
|
||||
*/
|
||||
export const isRetriableError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
};
|
||||
|
||||
export const isNeverSentError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && NEVER_SENT_ERROR_CODES.has(code));
|
||||
};
|
||||
|
||||
/**
|
||||
* Lock-contention failures the server rolled back on its own. Safe to retry a
|
||||
* single statement on, writes included — see `ROLLED_BACK_ERROR_CODES` for the
|
||||
* one-statement precondition.
|
||||
*/
|
||||
export const isRolledBackError = (error: unknown): boolean => {
|
||||
const code = errorCode(error);
|
||||
return Boolean(code && ROLLED_BACK_ERROR_CODES.has(code));
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -20,12 +20,11 @@
|
||||
/**
|
||||
* DELIMITER-aware splitter for MySQL dump / migration files.
|
||||
*
|
||||
* Splits `sql` into individual statements using the active statement
|
||||
* delimiter (default `;`). Recognises `DELIMITER X` lines, single-quoted
|
||||
* strings, double-quoted strings, backtick-quoted identifiers, line
|
||||
* comments (`-- `, `#`) and block comments (`/* ... *\/`). DELIMITER
|
||||
* directives are stripped from the output (they're a client-side concept,
|
||||
* not server SQL).
|
||||
* Splits `sql` into individual statements using the active statement delimiter
|
||||
* (default `;`). Recognises `DELIMITER X` lines, single-quoted strings,
|
||||
* double-quoted strings, backtick-quoted identifiers, line comments (`-- `,
|
||||
* `#`) and block comments (`/* ... *\/`). DELIMITER directives are stripped
|
||||
* from the output (they're a client-side concept, not server SQL).
|
||||
*
|
||||
* Returns trimmed, non-empty statements without the trailing delimiter.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { splitPostgresStatements } from './splitPostgresStatements';
|
||||
|
||||
describe('splitPostgresStatements', () => {
|
||||
it('splits on semicolons and trims each statement', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT 1;\n SELECT 2 ;\nSELECT 3'),
|
||||
).toEqual(['SELECT 1', 'SELECT 2', 'SELECT 3']);
|
||||
});
|
||||
|
||||
it('drops empty statements from stray semicolons', () => {
|
||||
expect(splitPostgresStatements(';;SELECT 1;;')).toEqual(['SELECT 1']);
|
||||
});
|
||||
|
||||
it('returns nothing for whitespace-only input', () => {
|
||||
expect(splitPostgresStatements(' \n\t ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps semicolons inside single-quoted literals', () => {
|
||||
expect(
|
||||
splitPostgresStatements("INSERT INTO t VALUES ('a;b'); SELECT 1"),
|
||||
).toEqual(["INSERT INTO t VALUES ('a;b')", 'SELECT 1']);
|
||||
});
|
||||
|
||||
it('handles doubled and backslash-escaped quotes in literals', () => {
|
||||
expect(
|
||||
splitPostgresStatements("SELECT 'it''s; fine'; SELECT 2"),
|
||||
).toEqual(["SELECT 'it''s; fine'", 'SELECT 2']);
|
||||
expect(splitPostgresStatements("SELECT E'a\\'; b'; SELECT 2")).toEqual([
|
||||
"SELECT E'a\\'; b'",
|
||||
'SELECT 2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps semicolons inside quoted identifiers', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT "we;ird" FROM t; SELECT 2'),
|
||||
).toEqual(['SELECT "we;ird" FROM t', 'SELECT 2']);
|
||||
});
|
||||
|
||||
it('handles a doubled quote inside a quoted identifier', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT "we""ird;x" FROM t; SELECT 2'),
|
||||
).toEqual(['SELECT "we""ird;x" FROM t', 'SELECT 2']);
|
||||
});
|
||||
|
||||
it('ignores semicolons inside line comments', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT 1 -- trailing; note\n; SELECT 2'),
|
||||
).toEqual(['SELECT 1 -- trailing; note', 'SELECT 2']);
|
||||
});
|
||||
|
||||
it('ignores semicolons inside block comments', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT /* a; b */ 1; SELECT 2'),
|
||||
).toEqual(['SELECT /* a; b */ 1', 'SELECT 2']);
|
||||
});
|
||||
|
||||
it('keeps a whole dollar-quoted function body together', () => {
|
||||
const sql = [
|
||||
'CREATE FUNCTION bump() RETURNS trigger AS $$',
|
||||
'BEGIN',
|
||||
' NEW.updated_at := now();',
|
||||
' RETURN NEW;',
|
||||
'END;',
|
||||
'$$ LANGUAGE plpgsql;',
|
||||
'SELECT 1',
|
||||
].join('\n');
|
||||
|
||||
const statements = splitPostgresStatements(sql);
|
||||
expect(statements).toHaveLength(2);
|
||||
expect(statements[0]).toContain('RETURN NEW;');
|
||||
expect(statements[0].endsWith('$$ LANGUAGE plpgsql')).toBe(true);
|
||||
expect(statements[1]).toBe('SELECT 1');
|
||||
});
|
||||
|
||||
it('respects a tagged dollar quote and its nested untagged pair', () => {
|
||||
const sql = 'SELECT $body$ inner $$ still; inside $body$; SELECT 2';
|
||||
expect(splitPostgresStatements(sql)).toEqual([
|
||||
'SELECT $body$ inner $$ still; inside $body$',
|
||||
'SELECT 2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a bare dollar sign as ordinary text', () => {
|
||||
expect(splitPostgresStatements('SELECT 1 $ 2; SELECT 3')).toEqual([
|
||||
'SELECT 1 $ 2',
|
||||
'SELECT 3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits an unterminated final statement', () => {
|
||||
expect(
|
||||
splitPostgresStatements('SELECT 1; SELECT 2 -- no newline'),
|
||||
).toEqual(['SELECT 1', 'SELECT 2 -- no newline']);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* 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 { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DDBClient } from './DDBClient';
|
||||
|
||||
const TABLE = 'kv-items';
|
||||
const GSI = 'by-kind';
|
||||
|
||||
const localConfig = (): IConfig =>
|
||||
({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: { inMemory: true, bootstrapTables: true },
|
||||
}) as unknown as IConfig;
|
||||
|
||||
/**
|
||||
* The emulator reports a table as CREATING for a tick after `CreateTable`
|
||||
* returns, so wait until it actually answers a read before using it.
|
||||
*/
|
||||
const awaitTable = async (
|
||||
client: DDBClient,
|
||||
table: string,
|
||||
key: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
await client.get(table, key);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'ResourceNotFoundException')
|
||||
throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
throw new Error(`table ${table} never became queryable`);
|
||||
};
|
||||
|
||||
const tableSchema = {
|
||||
TableName: TABLE,
|
||||
KeySchema: [
|
||||
{ AttributeName: 'pk', KeyType: 'HASH' as const },
|
||||
{ AttributeName: 'sk', KeyType: 'RANGE' as const },
|
||||
],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' as const },
|
||||
{ AttributeName: 'sk', AttributeType: 'S' as const },
|
||||
{ AttributeName: 'kind', AttributeType: 'S' as const },
|
||||
],
|
||||
GlobalSecondaryIndexes: [
|
||||
{
|
||||
IndexName: GSI,
|
||||
KeySchema: [{ AttributeName: 'kind', KeyType: 'HASH' as const }],
|
||||
Projection: { ProjectionType: 'ALL' as const },
|
||||
ProvisionedThroughput: {
|
||||
ReadCapacityUnits: 5,
|
||||
WriteCapacityUnits: 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
|
||||
};
|
||||
|
||||
describe('DDBClient — item operations', () => {
|
||||
let client: DDBClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new DDBClient(localConfig());
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
|
||||
});
|
||||
|
||||
it('round-trips an item and reports consumed capacity', async () => {
|
||||
await client.put(TABLE, { pk: 'u1', sk: 'profile', name: 'ada' });
|
||||
|
||||
const result = await client.get(TABLE, { pk: 'u1', sk: 'profile' });
|
||||
expect(result.Item).toEqual({ pk: 'u1', sk: 'profile', name: 'ada' });
|
||||
expect(result.ConsumedCapacity?.TableName).toBe(TABLE);
|
||||
});
|
||||
|
||||
it('returns no Item for a key that does not exist', async () => {
|
||||
const result = await client.get(TABLE, { pk: 'nobody', sk: 'here' });
|
||||
expect(result.Item).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honours a strongly consistent read', async () => {
|
||||
await client.put(TABLE, { pk: 'u2', sk: 'profile', name: 'grace' });
|
||||
const result = await client.get(
|
||||
TABLE,
|
||||
{ pk: 'u2', sk: 'profile' },
|
||||
true,
|
||||
);
|
||||
expect(result.Item?.name).toBe('grace');
|
||||
});
|
||||
|
||||
it('applies an update expression and returns the new item', async () => {
|
||||
await client.put(TABLE, { pk: 'u3', sk: 'counter', hits: 1 });
|
||||
const updated = await client.update(
|
||||
TABLE,
|
||||
{ pk: 'u3', sk: 'counter' },
|
||||
'SET #h = :next',
|
||||
{ ':next': 5 },
|
||||
{ '#h': 'hits' },
|
||||
);
|
||||
expect(updated.Attributes).toMatchObject({ hits: 5 });
|
||||
});
|
||||
|
||||
it('supports an update expression with no values or names', async () => {
|
||||
await client.put(TABLE, { pk: 'u4', sk: 'doc', stale: true });
|
||||
const updated = await client.update(
|
||||
TABLE,
|
||||
{ pk: 'u4', sk: 'doc' },
|
||||
'REMOVE stale',
|
||||
);
|
||||
expect(updated.Attributes).toEqual({ pk: 'u4', sk: 'doc' });
|
||||
});
|
||||
|
||||
it('deletes an item', async () => {
|
||||
await client.put(TABLE, { pk: 'u5', sk: 'temp' });
|
||||
await client.del(TABLE, { pk: 'u5', sk: 'temp' });
|
||||
const result = await client.get(TABLE, { pk: 'u5', sk: 'temp' });
|
||||
expect(result.Item).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fetches keys from several tables in one batch', async () => {
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
|
||||
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
|
||||
|
||||
const result = await client.batchGet([
|
||||
{ table: TABLE, items: { pk: 'b1', sk: 'a' } },
|
||||
{ table: TABLE, items: { pk: 'b1', sk: 'b' } },
|
||||
]);
|
||||
|
||||
const values = (result.Responses?.[TABLE] ?? [])
|
||||
.map((item) => item.v as number)
|
||||
.sort();
|
||||
expect(values).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — queries', () => {
|
||||
let client: DDBClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new DDBClient(localConfig());
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
|
||||
for (const sk of ['post#1', 'post#2', 'post#3', 'note#1']) {
|
||||
await client.put(TABLE, {
|
||||
pk: 'q1',
|
||||
sk,
|
||||
kind: sk.split('#')[0],
|
||||
size: sk === 'post#2' ? 10 : 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('queries by partition key', async () => {
|
||||
const result = await client.query(TABLE, { pk: 'q1' });
|
||||
expect(result.Items).toHaveLength(4);
|
||||
expect(result.ConsumedCapacity?.TableName).toBe(TABLE);
|
||||
});
|
||||
|
||||
it('narrows the sort key with begins_with', async () => {
|
||||
const result = await client.query(
|
||||
TABLE,
|
||||
{ pk: 'q1' },
|
||||
0,
|
||||
undefined,
|
||||
'',
|
||||
false,
|
||||
{ beginsWith: { key: 'sk', value: 'post#' } },
|
||||
);
|
||||
expect(result.Items?.map((item) => item.sk)).toEqual([
|
||||
'post#1',
|
||||
'post#2',
|
||||
'post#3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores an empty begins_with value', async () => {
|
||||
const result = await client.query(
|
||||
TABLE,
|
||||
{ pk: 'q1' },
|
||||
0,
|
||||
undefined,
|
||||
'',
|
||||
false,
|
||||
{ beginsWith: { key: 'sk', value: '' } },
|
||||
);
|
||||
expect(result.Items).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('applies a filter expression with its own names and values', async () => {
|
||||
const result = await client.query(
|
||||
TABLE,
|
||||
{ pk: 'q1' },
|
||||
0,
|
||||
undefined,
|
||||
'',
|
||||
false,
|
||||
{
|
||||
filter: {
|
||||
expression: '#s > :min',
|
||||
names: { '#s': 'size' },
|
||||
values: { ':min': 5 },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.Items?.map((item) => item.sk)).toEqual(['post#2']);
|
||||
});
|
||||
|
||||
it('counts without returning items', async () => {
|
||||
const result = await client.query(
|
||||
TABLE,
|
||||
{ pk: 'q1' },
|
||||
0,
|
||||
undefined,
|
||||
'',
|
||||
false,
|
||||
{ select: 'COUNT' },
|
||||
);
|
||||
expect(result.Count).toBe(4);
|
||||
expect(result.Items).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pages through results with the last evaluated key', async () => {
|
||||
const seen: string[] = [];
|
||||
let pageKey: Record<string, unknown> | undefined;
|
||||
let pages = 0;
|
||||
|
||||
do {
|
||||
const page = await client.query(TABLE, { pk: 'q1' }, 2, pageKey);
|
||||
pages += 1;
|
||||
expect(page.Items?.length).toBeLessThanOrEqual(2);
|
||||
for (const item of page.Items ?? []) seen.push(item.sk as string);
|
||||
pageKey = page.LastEvaluatedKey;
|
||||
} while (pageKey && pages < 10);
|
||||
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect(new Set(seen).size).toBe(4);
|
||||
});
|
||||
|
||||
it('queries a secondary index', async () => {
|
||||
const result = await client.query(
|
||||
TABLE,
|
||||
{ kind: 'note' },
|
||||
0,
|
||||
undefined,
|
||||
GSI,
|
||||
);
|
||||
expect(result.Items?.map((item) => item.sk)).toEqual(['note#1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — batch writes', () => {
|
||||
let client: DDBClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
client = new DDBClient(localConfig());
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
|
||||
});
|
||||
|
||||
it('reports no consumed capacity for an empty batch', async () => {
|
||||
await expect(client.batchPut([])).resolves.toEqual({
|
||||
ConsumedCapacity: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('splits a batch larger than the service limit into chunks', async () => {
|
||||
const params = Array.from({ length: 60 }, (_, index) => ({
|
||||
table: TABLE,
|
||||
item: { pk: 'bulk', sk: `item-${index}`, index },
|
||||
}));
|
||||
|
||||
const result = await client.batchPut(params);
|
||||
|
||||
expect(result.ConsumedCapacity).toHaveLength(1);
|
||||
expect(result.ConsumedCapacity[0].TableName).toBe(TABLE);
|
||||
expect(result.ConsumedCapacity[0].CapacityUnits).toBeGreaterThan(0);
|
||||
|
||||
const stored = await client.query(TABLE, { pk: 'bulk' });
|
||||
expect(stored.Items).toHaveLength(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — expired item sweep', () => {
|
||||
it('deletes only the items past their ttl when the table already existed', async () => {
|
||||
const client = new DDBClient(localConfig());
|
||||
const table = 'ttl-items';
|
||||
const schema = {
|
||||
TableName: table,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' as const },
|
||||
],
|
||||
ProvisionedThroughput: {
|
||||
ReadCapacityUnits: 5,
|
||||
WriteCapacityUnits: 5,
|
||||
},
|
||||
};
|
||||
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
await awaitTable(client, table, { pk: 'ready' });
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await client.put(table, { pk: 'stale', expireAt: now - 60 });
|
||||
await client.put(table, { pk: 'fresh', expireAt: now + 3600 });
|
||||
await client.put(table, { pk: 'eternal' });
|
||||
|
||||
// Second call finds the table in use, so the sweep runs.
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
|
||||
expect((await client.get(table, { pk: 'stale' })).Item).toBeUndefined();
|
||||
expect((await client.get(table, { pk: 'fresh' })).Item).toBeDefined();
|
||||
expect((await client.get(table, { pk: 'eternal' })).Item).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — expired item sweep on an empty table', () => {
|
||||
it('completes without deleting anything', async () => {
|
||||
const client = new DDBClient(localConfig());
|
||||
const table = 'ttl-empty';
|
||||
const schema = {
|
||||
TableName: table,
|
||||
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
|
||||
AttributeDefinitions: [
|
||||
{ AttributeName: 'pk', AttributeType: 'S' as const },
|
||||
],
|
||||
ProvisionedThroughput: {
|
||||
ReadCapacityUnits: 5,
|
||||
WriteCapacityUnits: 5,
|
||||
},
|
||||
};
|
||||
|
||||
await client.createTableIfNotExists(schema, 'expireAt');
|
||||
await awaitTable(client, table, { pk: 'ready' });
|
||||
await expect(
|
||||
client.createTableIfNotExists(schema, 'expireAt'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DDBClient — credentialled configuration', () => {
|
||||
it('refuses an aws config that is missing a key', () => {
|
||||
expect(
|
||||
() =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: { aws: { access_key: 'only-one' } },
|
||||
} as unknown as IConfig),
|
||||
).toThrow('requires both `access_key` and `secret_key`');
|
||||
});
|
||||
|
||||
it('skips table creation on a managed deployment', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint: 'http://127.0.0.1:1',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
// Would fail against the unreachable endpoint if it actually tried.
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Creating DynamoDB tables is disabled by default',
|
||||
),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('rebinds an aws-credentialled client on recreate', async () => {
|
||||
const client = new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b' },
|
||||
endpoint: 'http://127.0.0.1:1',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await expect(client.recreateClient()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rebinds the underlying client on recreate', async () => {
|
||||
const client = new DDBClient(localConfig());
|
||||
await client.createTableIfNotExists(tableSchema);
|
||||
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
|
||||
await client.put(TABLE, { pk: 'recreate', sk: 'a', v: 1 });
|
||||
|
||||
await client.recreateClient();
|
||||
|
||||
// Same in-memory store: the recreated client sees earlier writes.
|
||||
const result = await client.get(TABLE, { pk: 'recreate', sk: 'a' });
|
||||
expect(result.Item?.v).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Unprocessed items are a throttling signal the emulator never produces, so
|
||||
// the retry loop is driven against a stub speaking the DynamoDB wire format.
|
||||
describe('DDBClient — unprocessed batch items', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let responses: unknown[];
|
||||
let requestCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const next = responses.shift() ?? {};
|
||||
requestCount += 1;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const stubClient = () =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
const unprocessed = (pk: string) => ({
|
||||
UnprocessedItems: {
|
||||
[TABLE]: [{ PutRequest: { Item: { pk: { S: pk } } } }],
|
||||
},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
|
||||
});
|
||||
|
||||
it('retries the leftover items and sums capacity across attempts', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
unprocessed('retry-me'),
|
||||
{
|
||||
UnprocessedItems: {},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await stubClient().batchPut([
|
||||
{ table: TABLE, item: { pk: 'retry-me' } },
|
||||
]);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.ConsumedCapacity).toEqual([
|
||||
{ TableName: TABLE, CapacityUnits: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up with a bad-request error when items never drain', async () => {
|
||||
requestCount = 0;
|
||||
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
|
||||
|
||||
await expect(
|
||||
stubClient().batchPut([{ table: TABLE, item: { pk: 'stuck' } }]),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Failed to batch write all items to DynamoDB',
|
||||
});
|
||||
// One initial attempt plus the full retry budget.
|
||||
expect(requestCount).toBe(9);
|
||||
}, 30_000);
|
||||
|
||||
it('tolerates a response that reports no capacity at all', async () => {
|
||||
requestCount = 0;
|
||||
responses = [{ UnprocessedItems: {} }];
|
||||
|
||||
await expect(
|
||||
stubClient().batchPut([{ table: TABLE, item: { pk: 'quiet' } }]),
|
||||
).resolves.toEqual({ ConsumedCapacity: [] });
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores capacity entries with no table name', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
{
|
||||
UnprocessedItems: {},
|
||||
ConsumedCapacity: [{ CapacityUnits: 7 }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
stubClient().batchPut([{ table: TABLE, item: { pk: 'anon' } }]),
|
||||
).resolves.toEqual({ ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -43,6 +43,7 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
|
||||
await this.#localInitPromise;
|
||||
}
|
||||
|
||||
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
|
||||
async get<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchGet(
|
||||
params: { table: string; items: Record<string, unknown> }[],
|
||||
consistentRead = false,
|
||||
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
|
||||
async query<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
keys: T,
|
||||
@@ -336,7 +347,15 @@ export class DDBClient extends PuterClient {
|
||||
pageKey?: Record<string, unknown>,
|
||||
index = '',
|
||||
consistentRead = false,
|
||||
options?: { beginsWith?: { key: string; value: string } },
|
||||
options?: {
|
||||
beginsWith?: { key: string; value: string };
|
||||
select?: 'COUNT';
|
||||
filter?: {
|
||||
expression: string;
|
||||
values?: Record<string, unknown>;
|
||||
names?: Record<string, string>;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const keyExpressionParts = Object.keys(keys).map(
|
||||
(key) => `#${key} = :${key}`,
|
||||
@@ -367,6 +386,11 @@ export class DDBClient extends PuterClient {
|
||||
expressionAttributeNames[`#${beginsKey}`] = beginsKey;
|
||||
}
|
||||
|
||||
if (options?.filter) {
|
||||
Object.assign(expressionAttributeValues, options.filter.values);
|
||||
Object.assign(expressionAttributeNames, options.filter.names);
|
||||
}
|
||||
|
||||
const command = new QueryCommand({
|
||||
TableName: table,
|
||||
...(!index ? {} : { IndexName: index }),
|
||||
@@ -376,6 +400,10 @@ export class DDBClient extends PuterClient {
|
||||
ConsistentRead: consistentRead,
|
||||
...(!pageKey ? {} : { ExclusiveStartKey: pageKey }),
|
||||
...(!limit ? {} : { Limit: limit }),
|
||||
...(options?.filter
|
||||
? { FilterExpression: options.filter.expression }
|
||||
: {}),
|
||||
...(options?.select ? { Select: options.select } : {}),
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
|
||||
@@ -383,6 +411,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
|
||||
async update<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -424,6 +453,7 @@ export class DDBClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
let alreadyExisted = false;
|
||||
try {
|
||||
const client = await this.#getDocumentClient();
|
||||
await client.send(new CreateTableCommand(params));
|
||||
@@ -431,14 +461,33 @@ export class DDBClient extends PuterClient {
|
||||
if ((error as Error)?.name !== 'ResourceInUseException') {
|
||||
throw error;
|
||||
}
|
||||
alreadyExisted = true;
|
||||
}
|
||||
|
||||
if (ttlAttribute) {
|
||||
await this.#deleteExpiredItems(
|
||||
params.TableName!,
|
||||
params.KeySchema!,
|
||||
ttlAttribute,
|
||||
);
|
||||
// Only sweep a table that was already there. `CreateTable` returns
|
||||
// while the table is still CREATING, so scanning it right away races
|
||||
// its transition to ACTIVE and intermittently throws
|
||||
// `ResourceNotFoundException` — and a table we just created holds no
|
||||
// items to expire anyway.
|
||||
if (ttlAttribute && alreadyExisted) {
|
||||
try {
|
||||
await this.#deleteExpiredItems(
|
||||
params.TableName!,
|
||||
params.KeySchema!,
|
||||
ttlAttribute,
|
||||
);
|
||||
} catch (error) {
|
||||
// The sweep is opportunistic cleanup, so a table that isn't
|
||||
// queryable yet must never fail boot. Emulators differ on
|
||||
// whether `CreateTable` reports an existing table as in-use,
|
||||
// which can still land us here on a CREATING table.
|
||||
if ((error as Error)?.name !== 'ResourceNotFoundException') {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`[ddb] skipped TTL sweep for ${params.TableName}: table not queryable yet`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { EmailClient } from './EmailClient';
|
||||
|
||||
const FROM = '"Puter" <[email protected]>';
|
||||
|
||||
/**
|
||||
* Nodemailer's JSON transport is a real transport that serializes the message
|
||||
* instead of talking SMTP — the send path stays genuine while the wire stays
|
||||
* local.
|
||||
*/
|
||||
const jsonTransportConfig = (overrides: Partial<IConfig> = {}): IConfig =>
|
||||
({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
env: 'prod',
|
||||
email: { jsonTransport: true, from: FROM },
|
||||
...overrides,
|
||||
}) as unknown as IConfig;
|
||||
|
||||
const startClient = (overrides: Partial<IConfig> = {}): EmailClient => {
|
||||
const client = new EmailClient(jsonTransportConfig(overrides));
|
||||
client.onServerStart();
|
||||
return client;
|
||||
};
|
||||
|
||||
/** The JSON transport reports the serialized message on `.message`. */
|
||||
const sentMessage = (info: unknown) =>
|
||||
JSON.parse((info as { message: string }).message) as {
|
||||
from: { address: string; name: string };
|
||||
to: { address: string }[];
|
||||
subject: string;
|
||||
html?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe('EmailClient — transport lifecycle', () => {
|
||||
it('reports as configured once a transport is wired up', () => {
|
||||
const client = startClient();
|
||||
expect(client.isConfigured).toBe(true);
|
||||
client.onServerShutdown();
|
||||
expect(client.isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('warns and stays unconfigured when no transport is set', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new EmailClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
} as unknown as IConfig);
|
||||
client.onServerStart();
|
||||
|
||||
expect(client.isConfigured).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no email transport configured'),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('drops the send and returns null when no transport exists', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const client = new EmailClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
} as unknown as IConfig);
|
||||
client.onServerStart();
|
||||
|
||||
await expect(
|
||||
client.sendRaw({ to: '[email protected]', subject: 'hi', text: 'hi' }),
|
||||
).resolves.toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('without transport'),
|
||||
expect.objectContaining({ to: '[email protected]' }),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient — sending', () => {
|
||||
it('sends a rendered template through the transport', async () => {
|
||||
const client = startClient();
|
||||
const info = await client.sendRaw({
|
||||
to: '[email protected]',
|
||||
subject: 'raw subject',
|
||||
html: '<p>hello</p>',
|
||||
headers: { 'List-Unsubscribe': '<https://puter.test/u>' },
|
||||
});
|
||||
|
||||
const message = sentMessage(info);
|
||||
expect(message.subject).toBe('raw subject');
|
||||
expect(message.to[0].address).toBe('[email protected]');
|
||||
expect(message.from.address).toBe('[email protected]');
|
||||
expect(message.headers?.['List-Unsubscribe']).toBe(
|
||||
'<https://puter.test/u>',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the caller override the from address', async () => {
|
||||
const client = startClient();
|
||||
const info = await client.sendRaw({
|
||||
from: '[email protected]',
|
||||
to: '[email protected]',
|
||||
subject: 's',
|
||||
text: 't',
|
||||
});
|
||||
expect(sentMessage(info).from.address).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('falls back to the built-in sender when config has none', async () => {
|
||||
const client = startClient({
|
||||
email: { jsonTransport: true },
|
||||
} as unknown as Partial<IConfig>);
|
||||
const info = await client.sendRaw({
|
||||
to: '[email protected]',
|
||||
subject: 's',
|
||||
text: 't',
|
||||
});
|
||||
expect(sentMessage(info).from.address).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('refuses an unknown template name', async () => {
|
||||
const client = startClient();
|
||||
await expect(
|
||||
client.send('[email protected]', 'not-a-template' as never),
|
||||
).rejects.toThrow('Unknown email template: not-a-template');
|
||||
});
|
||||
|
||||
it('surfaces transport failures to the caller', async () => {
|
||||
// Port 1 on loopback is never listening, so the send really fails.
|
||||
const client = startClient({
|
||||
email: { host: '127.0.0.1', port: 1, from: FROM },
|
||||
} as unknown as Partial<IConfig>);
|
||||
|
||||
await expect(
|
||||
client.sendRaw({
|
||||
to: '[email protected]',
|
||||
subject: 's',
|
||||
text: 't',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient — template rendering', () => {
|
||||
it('renders the code into both subject and body', async () => {
|
||||
const client = startClient();
|
||||
const captured: { subject?: string; html?: string } = {};
|
||||
const original = client.sendRaw.bind(client);
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.subject = options.subject;
|
||||
captured.html = options.html;
|
||||
return original(options);
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'email_verification_code', {
|
||||
code: '424242',
|
||||
});
|
||||
|
||||
expect(captured.subject).toBe('424242 is your confirmation code');
|
||||
expect(captured.html).toContain('<strong>424242</strong>');
|
||||
});
|
||||
|
||||
it('does not HTML-escape values in the plain-text subject header', async () => {
|
||||
const client = startClient();
|
||||
const captured: { subject?: string } = {};
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
captured.subject = options.subject;
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'app-user-feedback', {
|
||||
owner_username: 'dev',
|
||||
sender_username: 'user',
|
||||
sender_email: null,
|
||||
app_title: "Bob's App & Games",
|
||||
app_name: 'bobs-app',
|
||||
app_link: 'https://puter.example/app/bobs-app',
|
||||
message: 'hi',
|
||||
});
|
||||
|
||||
// A subject is not HTML — entities would render literally in the
|
||||
// recipient's mail client.
|
||||
expect(captured.subject).toBe("New user feedback for Bob's App & Games");
|
||||
});
|
||||
|
||||
it('escapes html and converts newlines in nl2br values', async () => {
|
||||
const client = startClient();
|
||||
let html = '';
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
html = options.html ?? '';
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'listing-rejected', {
|
||||
app_name: 'demo',
|
||||
app_title: 'Demo',
|
||||
reason: 'first & "only"\n<b>line</b>',
|
||||
});
|
||||
|
||||
expect(html).toContain(
|
||||
'first & "only"<br /><b>line</b>',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an empty string for a missing nl2br value', async () => {
|
||||
const client = startClient();
|
||||
let html = '';
|
||||
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
|
||||
html = options.html ?? '';
|
||||
return null;
|
||||
});
|
||||
|
||||
await client.send('[email protected]', 'listing-rejected', {
|
||||
app_name: 'demo',
|
||||
app_title: 'Demo',
|
||||
});
|
||||
|
||||
expect(html).toContain('<blockquote></blockquote>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient.clean', () => {
|
||||
const clean = (email: string) => startClient().clean(email);
|
||||
|
||||
it('strips subaddressing on ordinary domains', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('ignores dots and subaddressing for gmail', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('canonicalizes googlemail onto gmail', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('keeps yahoo subaddressing, which yahoo treats as distinct', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('ignores dots for icloud aliases', () => {
|
||||
expect(clean('[email protected]')).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('returns anything that is not an address unchanged', () => {
|
||||
expect(clean('not-an-email')).toBe('not-an-email');
|
||||
expect(clean('@nolocal.test')).toBe('@nolocal.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmailClient.validate', () => {
|
||||
it('waves everything through in a dev environment', async () => {
|
||||
const client = startClient({ env: 'dev' } as Partial<IConfig>);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a blocked domain suffix', async () => {
|
||||
const client = startClient({
|
||||
blockedEmailDomains: ['blocked.test'],
|
||||
} as unknown as Partial<IConfig>);
|
||||
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('matches the blocklist against the cleaned address', async () => {
|
||||
const client = startClient({
|
||||
blockedEmailDomains: ['@gmail.com'],
|
||||
} as unknown as Partial<IConfig>);
|
||||
await expect(
|
||||
client.validate('[email protected]'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('lets a registered validator veto an address', async () => {
|
||||
const client = startClient();
|
||||
const seen: string[] = [];
|
||||
client.addValidator((email) => {
|
||||
seen.push(email);
|
||||
return !email.startsWith('spam');
|
||||
});
|
||||
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(true);
|
||||
// Validators always see the canonical form.
|
||||
expect(seen).toEqual(['[email protected]', '[email protected]']);
|
||||
});
|
||||
|
||||
it('supports asynchronous validators', async () => {
|
||||
const client = startClient();
|
||||
client.addValidator(async (email) => email !== '[email protected]');
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(false);
|
||||
await expect(client.validate('[email protected]')).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||
close?: () => void;
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
export interface SentMessageInfo {
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
@@ -37,10 +45,18 @@ export interface SendMailOptions {
|
||||
to: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
/** Optional transport recipients when they differ from visible headers. */
|
||||
envelope?: {
|
||||
from?: string;
|
||||
to: string;
|
||||
};
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
/** Extra message headers (e.g. List-Unsubscribe), passed to the transport. */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
@@ -94,13 +110,15 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* Unified email client. Handles:
|
||||
* - Template-based outbound mail (via `send`)
|
||||
* - Raw nodemailer passthrough (via `sendRaw`)
|
||||
* - Canonical-form normalization for dedup (via `clean`)
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*
|
||||
* - Template-based outbound mail (via `send`)
|
||||
* - Raw nodemailer passthrough (via `sendRaw`)
|
||||
* - Canonical-form normalization for dedup (via `clean`)
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: NodemailerTransport | null = null;
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
null;
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
@@ -123,8 +141,7 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.transport = nodemailer.createTransport(emailConf as any);
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -136,12 +153,15 @@ export class EmailClient extends PuterClient {
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a template and send it to `to`.
|
||||
* 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).
|
||||
*/
|
||||
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) {
|
||||
@@ -153,33 +173,43 @@ export class EmailClient extends PuterClient {
|
||||
to,
|
||||
subject: compiled.subject(values),
|
||||
html: compiled.html(values),
|
||||
...(options.replyTo ? { replyTo: options.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw send — bypasses the template system. Useful for one-off
|
||||
* admin emails that don't warrant a named template.
|
||||
* Raw send — bypasses the template system. Useful for one-off admin emails
|
||||
* that don't warrant a named template.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport is
|
||||
* configured (the send is a no-op in that case — callers that must not
|
||||
* silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions): Promise<void> {
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
console.warn(
|
||||
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
|
||||
options,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
return await this.transport.sendMail({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
* Normalize an email to its canonical form for dedup comparisons.
|
||||
* Applies provider-specific rules (e.g. Gmail ignores dots in
|
||||
* the local part) plus generic subaddressing removal.
|
||||
* Normalize an email to its canonical form for dedup comparisons. Applies
|
||||
* provider-specific rules (e.g. Gmail ignores dots in the local part) plus
|
||||
* generic subaddressing removal.
|
||||
*/
|
||||
clean(email: string): string {
|
||||
let [local, domain] = email.split('@');
|
||||
@@ -208,9 +238,9 @@ export class EmailClient extends PuterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an email is allowed to be used. Checks domain
|
||||
* blocklist plus any registered validators (services can call
|
||||
* `addValidator()` to register custom policy hooks).
|
||||
* Check whether an email is allowed to be used. Checks domain blocklist
|
||||
* plus any registered validators (services can call `addValidator()` to
|
||||
* register custom policy hooks).
|
||||
*/
|
||||
async validate(email: string): Promise<boolean> {
|
||||
if (this.config.env === 'dev') return true;
|
||||
@@ -233,9 +263,8 @@ export class EmailClient extends PuterClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom validation hook. Services can call this
|
||||
* during their startup to veto specific emails (e.g. a
|
||||
* disposable-email service).
|
||||
* Register a custom validation hook. Services can call this during their
|
||||
* startup to veto specific emails (e.g. a disposable-email service).
|
||||
*/
|
||||
addValidator(fn: EmailValidator): void {
|
||||
this.validators.push(fn);
|
||||
@@ -262,7 +291,14 @@ export class EmailClient extends PuterClient {
|
||||
private compileTemplates(): void {
|
||||
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
|
||||
this.compiledTemplates[name as EmailTemplateName] = {
|
||||
subject: handlebars.compile(template.subject),
|
||||
// Subjects are plain-text headers: HTML-escaping would put
|
||||
// literal entities in front of the recipient (& etc.).
|
||||
// Header safety is handled elsewhere — the transport encodes
|
||||
// newlines, and free-form values (e.g. app_title) collapse
|
||||
// whitespace upstream.
|
||||
subject: handlebars.compile(template.subject, {
|
||||
noEscape: true,
|
||||
}),
|
||||
html: handlebars.compile(dedent(template.html)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -18,12 +18,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Email template definitions. Keys are the template names; values are
|
||||
* the Handlebars-compilable `subject` and `html` strings.
|
||||
* Email template definitions. Keys are the template names; values are the
|
||||
* Handlebars-compilable `subject` and `html` strings.
|
||||
*
|
||||
* Rendered values are supplied by callers of `EmailClient.send()`.
|
||||
* Variables use standard Handlebars syntax: `{{var}}`, `{{#if cond}}…{{/if}}`,
|
||||
* and the custom helper `{{{nl2br text}}}` for HTML-safe newline conversion.
|
||||
* Rendered values are supplied by callers of `EmailClient.send()`. Variables
|
||||
* use standard Handlebars syntax: `{{var}}`, `{{#if cond}}…{{/if}}`, and the
|
||||
* custom helper `{{{nl2br text}}}` for HTML-safe newline conversion.
|
||||
*/
|
||||
|
||||
export interface EmailTemplate {
|
||||
@@ -75,6 +75,25 @@ Please update <a href="https://puter.com/app/{{app_name}}">{{app_title}}</a>.
|
||||
<blockquote>{{nl2br message}}</blockquote>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
'app-user-feedback': {
|
||||
subject: 'New user feedback for {{app_title}}',
|
||||
html: `
|
||||
<p>Hi{{#if owner_username}} {{owner_username}}{{/if}},</p>
|
||||
<p>
|
||||
<strong>{{sender_username}}</strong>{{#if sender_email}} (<a href="mailto:{{sender_email}}">{{sender_email}}</a>){{/if}} sent feedback about <a href="{{app_link}}">{{app_title}}</a>:
|
||||
</p>
|
||||
<blockquote>{{{nl2br message}}}</blockquote>
|
||||
{{#if sender_email}}<p>Just reply to this email to respond to them directly.</p>{{/if}}
|
||||
<p>
|
||||
You're receiving this because feedback is enabled for
|
||||
<a href="{{app_link}}">{{app_title}}</a> — manage it in the
|
||||
<a href="{{dev_center_link}}">Dev Center</a> under your app's settings.
|
||||
</p>
|
||||
<p>Best,<br />
|
||||
The Puter Team
|
||||
</p>
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { extensionStore } from '../../extensions.ts';
|
||||
import { PuterServer } from '../../server.ts';
|
||||
import { setupTestServer } from '../../testUtil.ts';
|
||||
import type { EventClient } from './EventClient.js';
|
||||
@@ -21,12 +23,11 @@ describe('EventClient', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
// Each test gets a fresh key so listeners registered by earlier tests
|
||||
// never collide with later ones (the client has no public way to
|
||||
// unsubscribe).
|
||||
// never collide with later ones.
|
||||
let key: string;
|
||||
beforeEach(() => {
|
||||
key = `test.${Math.random().toString(36).slice(2)}`;
|
||||
@@ -184,6 +185,106 @@ describe('EventClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('off', () => {
|
||||
it('stops delivering to a removed listener', () => {
|
||||
const kept = vi.fn();
|
||||
const removed = vi.fn();
|
||||
target.on(key, kept);
|
||||
target.on(key, removed);
|
||||
|
||||
target.off(key, removed);
|
||||
target.emit(key, {}, {});
|
||||
|
||||
expect(kept).toHaveBeenCalledTimes(1);
|
||||
expect(removed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a key nobody ever subscribed to', () => {
|
||||
expect(() => target.off(key, vi.fn())).not.toThrow();
|
||||
});
|
||||
|
||||
it('leaves the list alone when the listener was never registered', () => {
|
||||
const listener = vi.fn();
|
||||
target.on(key, listener);
|
||||
|
||||
target.off(key, vi.fn());
|
||||
target.emit(key, {}, {});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasListeners', () => {
|
||||
it('is false for a key nobody subscribed to', () => {
|
||||
expect(target.hasListeners(key)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for an exact subscriber', () => {
|
||||
target.on(key, vi.fn());
|
||||
expect(target.hasListeners(key)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when only a wildcard prefix matches', () => {
|
||||
target.on(`${key}.*`, vi.fn());
|
||||
expect(target.hasListeners(`${key}.child.deep`)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for an extension-registered listener', () => {
|
||||
extensionStore.events[key] = [vi.fn()];
|
||||
try {
|
||||
expect(target.hasListeners(key)).toBe(true);
|
||||
} finally {
|
||||
delete extensionStore.events[key];
|
||||
}
|
||||
});
|
||||
|
||||
it('goes back to false once the last listener is removed', () => {
|
||||
const listener = vi.fn();
|
||||
target.on(key, listener);
|
||||
target.off(key, listener);
|
||||
expect(target.hasListeners(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extension-registered listeners', () => {
|
||||
afterEach(() => {
|
||||
delete extensionStore.events[key];
|
||||
});
|
||||
|
||||
it('fires alongside listeners registered on the client', () => {
|
||||
const fromExtension = vi.fn();
|
||||
const fromClient = vi.fn();
|
||||
extensionStore.events[key] = [fromExtension];
|
||||
target.on(key, fromClient);
|
||||
|
||||
target.emit(key, { n: 1 }, { source: 'test' });
|
||||
|
||||
expect(fromExtension).toHaveBeenCalledWith(
|
||||
key,
|
||||
{ n: 1 },
|
||||
{ source: 'test' },
|
||||
);
|
||||
expect(fromClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is awaited by emitAndWait', async () => {
|
||||
const order: string[] = [];
|
||||
extensionStore.events[key] = [
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
order.push('extension');
|
||||
},
|
||||
];
|
||||
target.on(key, () => {
|
||||
order.push('client');
|
||||
});
|
||||
|
||||
await target.emitAndWait(key, {}, {});
|
||||
|
||||
expect(order).toEqual(['client', 'extension']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle hooks', () => {
|
||||
it('onServerStart emits a serverStart event', () => {
|
||||
const listener = vi.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EventListener,
|
||||
@@ -43,20 +44,19 @@ export class EventClient extends PuterClient {
|
||||
/**
|
||||
* Dispatch an event to every matching subscriber.
|
||||
*
|
||||
* Match semantics: emit walks every dot-separated prefix of `key`,
|
||||
* looking up `<prefix>.*` listeners for prefixes shorter than the full
|
||||
* key, and exact-key listeners on the final iteration. So emitting
|
||||
* Match semantics: emit walks every dot-separated prefix of `key`, looking
|
||||
* up `<prefix>.*` listeners for prefixes shorter than the full key, and
|
||||
* exact-key listeners on the final iteration. So emitting
|
||||
* `outer.gui.item.removed` fires subscribers on:
|
||||
*
|
||||
* - `outer.*`
|
||||
* - `outer.gui.*`
|
||||
* - `outer.gui.item.*`
|
||||
* - `outer.gui.item.removed`
|
||||
*
|
||||
* Subscribers are still keyed in a single map — wildcards just live
|
||||
* under their literal `<prefix>.*` string. No regex, no per-emit
|
||||
* scan of every listener.
|
||||
* - `outer.*`
|
||||
* - `outer.gui.*`
|
||||
* - `outer.gui.item.*`
|
||||
* - `outer.gui.item.removed`
|
||||
*
|
||||
* Subscribers are still keyed in a single map — wildcards just live under
|
||||
* their literal `<prefix>.*` string. No regex, no per-emit scan of every
|
||||
* listener.
|
||||
*/
|
||||
emit<T extends keyof EventMap>(
|
||||
key: T,
|
||||
@@ -84,21 +84,59 @@ export class EventClient extends PuterClient {
|
||||
/**
|
||||
* Like `emit`, but awaits every matched listener before resolving.
|
||||
*
|
||||
* Use this when the emitter needs to act on mutations the handlers made
|
||||
* to `data` — e.g. validation hooks where a listener can set
|
||||
* `data.allow = false` to reject, or pre-commit pipelines where every
|
||||
* stage must complete before the next step runs. Regular `emit` is
|
||||
* fire-and-forget and can't observe handler state changes.
|
||||
* Use this when the emitter needs to act on mutations the handlers made to
|
||||
* `data` — e.g. validation hooks where a listener can set `data.allow =
|
||||
* false` to reject, or pre-commit pipelines where every stage must complete
|
||||
* before the next step runs. Regular `emit` is fire-and-forget and can't
|
||||
* observe handler state changes.
|
||||
*
|
||||
* Listeners run sequentially in the order they're registered so an
|
||||
* earlier handler's mutation is visible to later ones. A listener that
|
||||
* throws is logged (same as `emit`) and the chain continues.
|
||||
* Listeners run sequentially in the order they're registered so an earlier
|
||||
* handler's mutation is visible to later ones. A listener that throws is
|
||||
* logged (same as `emit`) and the chain continues.
|
||||
*/
|
||||
async emitAndWait<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether anything would run if `key` were emitted, wildcards included.
|
||||
*
|
||||
* For emitters whose work is only worth doing when someone is listening —
|
||||
* gathering the payload costs a round trip, say. Emitting into the void is
|
||||
* otherwise perfectly cheap and doesn't need this.
|
||||
*/
|
||||
hasListeners<T extends keyof EventMap>(key: T): boolean {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
@@ -106,33 +144,22 @@ export class EventClient extends PuterClient {
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error('Error in event listener for event', key, e);
|
||||
}
|
||||
}
|
||||
if (this.#eventListeners[matchKey]?.length) return true;
|
||||
if (extensionStore.events[matchKey]?.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event by exact key OR a wildcard prefix.
|
||||
*
|
||||
* Wildcards: a key ending in `.*` matches every event whose name
|
||||
* starts with the prefix. `outer.*` matches `outer.gui.item.removed`,
|
||||
* `outer.fs.write-hash`, and any other dot-extended descendant.
|
||||
* Exact keys still match exactly. See `emit()` for the dispatch order.
|
||||
*
|
||||
* Callback receives the full `(key, data, meta)` tuple as passed
|
||||
* to `emit()` — wildcard subscribers can branch on the triggering
|
||||
* event name.
|
||||
* Wildcards: a key ending in `.*` matches every event whose name starts
|
||||
* with the prefix. `outer.*` matches `outer.gui.item.removed`,
|
||||
* `outer.fs.write-hash`, and any other dot-extended descendant. Exact keys
|
||||
* still match exactly. See `emit()` for the dispatch order.
|
||||
*
|
||||
* Callback receives the full `(key, data, meta)` tuple as passed to
|
||||
* `emit()` — wildcard subscribers can branch on the triggering event name.
|
||||
*/
|
||||
on<P extends ListenKey>(
|
||||
key: P,
|
||||
@@ -146,6 +173,19 @@ export class EventClient extends PuterClient {
|
||||
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
|
||||
listeners.push(callback as EventListener);
|
||||
}
|
||||
off<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners = this.#eventListeners[key];
|
||||
if (!listeners) return;
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
*
|
||||
* Domain objects (User, Actor, FSEntry, Socket, ...) are typed as `unknown`
|
||||
* here on purpose — pulling their real types in would couple this module to
|
||||
* most of the backend and risk import cycles. Refine at the listener if
|
||||
* you need narrowed access.
|
||||
* most of the backend and risk import cycles. Refine at the listener if you
|
||||
* need narrowed access.
|
||||
*
|
||||
* Conventions:
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject;
|
||||
* they tend to grow listener-specific fields, so they accept extras via
|
||||
* an index signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope
|
||||
* the SocketService fans out to user-scoped channels.
|
||||
*
|
||||
* - `*.validate` events carry an `allow` flag listeners flip to reject; they tend
|
||||
* to grow listener-specific fields, so they accept extras via an index
|
||||
* signature.
|
||||
* - `outer.gui.*` events ride the `{ user_id_list, response }` envelope the
|
||||
* SocketService fans out to user-scoped channels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
import { Actor } from '../../core';
|
||||
import type { UsageInput } from '../../services/metering/types';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
|
||||
// GUI write events spread an entry plus per-event metadata into `response`.
|
||||
@@ -116,6 +122,21 @@ export type EventMap = {
|
||||
actor: unknown;
|
||||
result: { allowed: boolean; reason?: string; error?: string };
|
||||
};
|
||||
// Emitted after an app token is granted (get-user-app-token). The app-side
|
||||
// analogue of `site.htmlServed` — the external-app abuse scanner
|
||||
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
|
||||
// external `index_url`s that Puter never serves itself.
|
||||
'puter.app.authenticated': {
|
||||
app_uid?: string;
|
||||
user_id?: number | null;
|
||||
app?: {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
'puter.signup.validate': {
|
||||
@@ -126,7 +147,22 @@ export type EventMap = {
|
||||
req?: unknown;
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
custom?: unknown;
|
||||
trail?: Array<string>;
|
||||
/**
|
||||
* Set by the abuse harness for flagged signups — the id under which the
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back
|
||||
* on the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/**
|
||||
* Set by the abuse harness — require SMS phone verification
|
||||
* post-signup.
|
||||
*/
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
requires_card_verification?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.signup.success': {
|
||||
@@ -135,6 +171,9 @@ export type EventMap = {
|
||||
email: string;
|
||||
username: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'email.validate': {
|
||||
@@ -154,12 +193,95 @@ export type EventMap = {
|
||||
user_uid: string;
|
||||
email: string;
|
||||
};
|
||||
// Phone-reuse cap is pure mechanism here — the abuse extension answers
|
||||
// (emitted via `emitAndWait`) by counting how many OTHER accounts have
|
||||
// already verified this number and flipping `allowed` to false when the
|
||||
// cross-account limit is hit. No extension listening → `allowed` stays
|
||||
// true and the send proceeds.
|
||||
'puter.phone-verification.check': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Fire-and-forget signal that a code was actually sent — the abuse
|
||||
// extension bumps its per-number / per-account / per-device send-velocity
|
||||
// counters off this. No-op with no extension listening.
|
||||
'puter.phone-verification.sent': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
device_fingerprint: string | null;
|
||||
};
|
||||
'user.phone-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
};
|
||||
// Card verification is pure mechanism here — a payments extension fills
|
||||
// these in (emitted via `emitAndWait`). `enabled` stays null when no
|
||||
// extension is installed; the extension always sets it (true/false) so
|
||||
// the endpoints can distinguish "disabled" from "not installed".
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
// accounts) before any Stripe SetupIntent is created.
|
||||
device_fingerprint: string | null;
|
||||
enabled: boolean | null;
|
||||
// Set false by the extension to refuse this setup (e.g. the per-device
|
||||
// setup-velocity cap); `reason` carries the opaque code. Stays true
|
||||
// when allowed or with no extension listening.
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
client_secret: string | null;
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
reason: string | null;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
// Fired after an account is torn down (self-serve, admin, or temp-user
|
||||
// logout cleanup). Listeners purge external state tied to the account —
|
||||
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
|
||||
// The row is already gone by emit time, so identifiers ride the payload.
|
||||
'user.delete': {
|
||||
user_id: number;
|
||||
user_uuid?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
};
|
||||
|
||||
// ---- Filesystem ----
|
||||
'fs.copy.node': {
|
||||
@@ -184,6 +306,38 @@ export type EventMap = {
|
||||
};
|
||||
'storage.quota.bonus': { userId: number; extra: number };
|
||||
|
||||
// ---- 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
|
||||
// record is touched) and how to record the answer, and an extension
|
||||
// decides what the account owes. Listeners push onto `charges`; the
|
||||
// service applies them as ordinary usage once every listener has run.
|
||||
// Nothing listening → no charges and no claim is ever taken.
|
||||
//
|
||||
// Emitted after the claim is settled, so a listener that calls back into
|
||||
// metering can't re-trigger it.
|
||||
'metering.monthly.charges': {
|
||||
/**
|
||||
* User-scoped, with no app on it: the account is what recurs, and the
|
||||
* app that happened to trigger the month's first call has nothing to do
|
||||
* with what is owed. Price against what the user owns.
|
||||
*/
|
||||
actor: Actor;
|
||||
/** The month being charged for, `YYYY-MM` in UTC. */
|
||||
month: string;
|
||||
/**
|
||||
* Push what the user owes here. Every listener's charges are merged
|
||||
* into one amount map and recorded as a single increment.
|
||||
*/
|
||||
charges: UsageInput[];
|
||||
};
|
||||
|
||||
// ---- Workers ----
|
||||
// Only a genuinely new worker. Redeploying an existing name updates its
|
||||
// row instead, and never reaches here — so a listener that prices this
|
||||
// is pricing workers that came into existence, not deploys.
|
||||
'worker.create': { actor: Actor; workerName: string };
|
||||
|
||||
// ---- Outer / GUI broadcast ----
|
||||
'outer.cacheUpdate': {
|
||||
cacheKey: string[];
|
||||
@@ -191,6 +345,15 @@ export type EventMap = {
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
'outer.fs.write-hash': { hash: string; uuid: string };
|
||||
/**
|
||||
* Cache keys the KV read cache must stop serving, because the entries
|
||||
* behind them were just written somewhere else.
|
||||
*
|
||||
* `outer.*` rather than `outer.pubsub.*` on purpose: the cache lives in the
|
||||
* Redis a cluster shares, so one node applying the invalidation covers the
|
||||
* whole cluster — fanning it out to siblings would just repeat the write.
|
||||
*/
|
||||
'outer.kv.cacheInvalidated': { cacheKeys: string[] };
|
||||
'outer.gui.item.added': GuiEvent;
|
||||
'outer.gui.item.updated': GuiEvent;
|
||||
'outer.gui.item.moved': GuiEvent;
|
||||
@@ -211,13 +374,19 @@ export type EventMap = {
|
||||
entry: unknown;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
requestUrl?: string;
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
// https:// URL becomes a signed URL) and falls back to `uuid`/`uid` to
|
||||
// migrate inline data-URL thumbnails. Declared to match: the previous
|
||||
// `uri` member was never emitted nor read.
|
||||
'thumbnail.read': {
|
||||
uri: string;
|
||||
size?: string;
|
||||
uuid?: string;
|
||||
uid?: string;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
'thumbnail.created': { url: string };
|
||||
@@ -257,6 +426,112 @@ export type EventMap = {
|
||||
// SocketService re-emits each fanout-eligible event under
|
||||
// `sent-to-user.<wireName>` so per-user channels can subscribe by wire name.
|
||||
[K in `sent-to-user.${string}`]: { user_id: number; response: unknown };
|
||||
} & {
|
||||
// Generic per-driver-method lifecycle, emitted by DriverController for
|
||||
// EVERY driver call under a key scoped to the interface + method:
|
||||
// `driver.<iface>.<method>.before|after|error|reject`. Wildcard
|
||||
// subscribers can listen to `driver.*` for everything, `driver.<iface>.*`
|
||||
// for one interface, or the exact key for one method. The `.before` phase
|
||||
// is emitted via `emitAndWait`, so a listener may set `allow = false`
|
||||
// (with an optional `rejectReason`) to veto the call before it runs — a
|
||||
// vetoed call emits `.reject` instead of running.
|
||||
[K in `driver.${string}`]: DriverMethodLifecycleEvent;
|
||||
} & {
|
||||
// Generic per-route-endpoint lifecycle, emitted by the route materializer
|
||||
// for EVERY non-middleware route under a key scoped to the HTTP method +
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: string };
|
||||
} & {
|
||||
/**
|
||||
* A user's subscription now resolves to a different policy. Carried on the
|
||||
* `outer.pubsub.*` channel so it reaches sibling nodes and peer clusters,
|
||||
* not just the one that handled the change — every node caches the resolved
|
||||
* policy, so a purchase is only live once they have all dropped theirs.
|
||||
*/
|
||||
'outer.pubsub.metering.subscription-changed': { userUuid: string };
|
||||
/**
|
||||
* A user's purchased credit balance changed. Separate from a policy change
|
||||
* because it moves the other half of the same budget, and carried on the
|
||||
* `outer.pubsub.*` channel for the same reason: every node caches whether
|
||||
* an account has budget left, so a top-up only lifts enforcement once they
|
||||
* have all dropped that answer.
|
||||
*/
|
||||
'outer.pubsub.metering.credits-changed': { userUuid: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
|
||||
* listener vetoes the call (sets `allow = false`); the call never runs and no
|
||||
* `after`/`error` follows.
|
||||
*/
|
||||
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
|
||||
|
||||
/**
|
||||
* Payload for `driver.<iface>.<method>.<phase>` events.
|
||||
*
|
||||
* One shape across all phases; read `phase` (or the key suffix) to branch.
|
||||
* `allow`/`rejectReason` are only meaningful on the `before` phase (emitted via
|
||||
* `emitAndWait`).
|
||||
*/
|
||||
export type DriverMethodLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
iface: string;
|
||||
method: string;
|
||||
/** Resolved concrete driver name. */
|
||||
driver: string;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Call arguments. Present on every phase. */
|
||||
args?: unknown;
|
||||
/** Return value. Present on `after`. */
|
||||
result?: unknown;
|
||||
/** Thrown error. Present on `error`. */
|
||||
error?: unknown;
|
||||
/** Wall-clock duration of the invocation. Present on `after`/`error`. */
|
||||
durationMs?: number;
|
||||
/** Veto channel for `before`: set `false` to block the call. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for `route.<method>.<path>.<phase>` events. Mirrors
|
||||
* {@link DriverMethodLifecycleEvent} for HTTP endpoints.
|
||||
*/
|
||||
export type RouteLifecycleEvent = {
|
||||
phase: LifecyclePhase;
|
||||
/** HTTP method, lowercased (`get`, `post`, ...). */
|
||||
method: string;
|
||||
/** Full route path including the controller prefix. */
|
||||
path: string;
|
||||
/**
|
||||
* The live express request/response for this call. Present on every phase.
|
||||
* On `before` a listener can read the parsed body/headers or write its own
|
||||
* response; on terminal phases they're useful for logging. These are real
|
||||
* in-process objects — never serialize or forward them across nodes.
|
||||
*/
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
/** Full actor object, if the request is authenticated. */
|
||||
actor?: Actor;
|
||||
/** Stable actor id (see `actorUid`), if the request is authenticated. */
|
||||
actorUid?: string;
|
||||
/** Response status code. Present on `after`/`error`. */
|
||||
statusCode?: number;
|
||||
/** Wall-clock duration from `before` to terminal phase. */
|
||||
durationMs?: number;
|
||||
/** Set when the request did not complete normally (abort / >=500). */
|
||||
error?: unknown;
|
||||
/** Veto channel for `before`: set `false` to block the request. */
|
||||
allow?: boolean;
|
||||
/** Optional human-readable reason surfaced to the caller when vetoed. */
|
||||
rejectReason?: string;
|
||||
};
|
||||
|
||||
export type EventKey = keyof EventMap & string;
|
||||
@@ -265,11 +540,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>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
@@ -24,6 +24,7 @@ import { EventClient } from './event/EventClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
import { PreludeClient } from './prelude/PreludeClient';
|
||||
import type { IPuterClientRegistry } from './types';
|
||||
|
||||
export const puterClients = {
|
||||
@@ -34,4 +35,5 @@ export const puterClients = {
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
prelude: PreludeClient,
|
||||
} satisfies IPuterClientRegistry;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PreludeClient } from './PreludeClient';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const makeClient = (apiKey?: string) =>
|
||||
new PreludeClient({
|
||||
prelude: apiKey ? { apiKey } : undefined,
|
||||
} as unknown as IConfig);
|
||||
|
||||
const okJson = (body: unknown) =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('PreludeClient', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('isConfigured reflects whether an apiKey is set', () => {
|
||||
expect(makeClient('sk_test').isConfigured()).toBe(true);
|
||||
expect(makeClient().isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isCountrySupported (€0.07 cap)', () => {
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
it('allows revenue markets up to the cap (incl. the priciest)', () => {
|
||||
expect(client.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(client.isCountrySupported('DE')).toBe(true); // €0.0598
|
||||
expect(client.isCountrySupported('SA')).toBe(true); // €0.0638
|
||||
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
|
||||
});
|
||||
|
||||
it('rejects countries above the cap, with no SMS, or unknown', () => {
|
||||
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
|
||||
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
|
||||
expect(client.isCountrySupported('LI')).toBe(false); // null (no SMS)
|
||||
expect(client.isCountrySupported('ZZ')).toBe(false); // unknown
|
||||
expect(client.isCountrySupported(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a configured maxSmsCostEur override', () => {
|
||||
const strict = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.01 },
|
||||
} as unknown as IConfig);
|
||||
expect(strict.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(strict.isCountrySupported('DE')).toBe(false); // €0.0598 > 0.01
|
||||
|
||||
const loose = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.5 },
|
||||
} as unknown as IConfig);
|
||||
expect(loose.isCountrySupported('PK')).toBe(true); // €0.3548 <= 0.5
|
||||
});
|
||||
});
|
||||
|
||||
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({ id: 'vrf_1', status: 'success' }),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ id: 'vrf_1', status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer sk_test');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
|
||||
// hardcoded to en-US so the message text is always English.
|
||||
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
|
||||
signals: { ip: '203.0.113.7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel sequence Prelude reports', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
id: 'vrf_1',
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
}),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123');
|
||||
|
||||
expect(res.channels).toEqual(['whatsapp', 'sms']);
|
||||
});
|
||||
|
||||
it('forwards device_id and user_agent signals when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).signals).toEqual({
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the signals object entirely when none are supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'signals',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
|
||||
expect(body.signals).toEqual({ ip: '203.0.113.7' });
|
||||
expect(body.signals).not.toHaveProperty('dispatch_id');
|
||||
});
|
||||
|
||||
it('omits dispatch_id when not supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'dispatch_id',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a configured template_id + sender_id + preferred channel', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = new PreludeClient({
|
||||
prelude: {
|
||||
apiKey: 'sk_test',
|
||||
templateId: 'tmpl_puter',
|
||||
senderId: 'Puter',
|
||||
preferredChannel: 'sms',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).options).toEqual({
|
||||
code_size: 6,
|
||||
preferred_channel: 'sms', // config override beats the rcs default
|
||||
locale: 'en-US',
|
||||
template_id: 'tmpl_puter',
|
||||
sender_id: 'Puter',
|
||||
});
|
||||
});
|
||||
|
||||
it('checkVerification POSTs target + code and returns the status', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.checkVerification('+14155550123', '123456');
|
||||
|
||||
expect(res).toEqual({ status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification/check');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws (does not call fetch) when not configured', async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
client.createVerification('+14155550123'),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-2xx Prelude response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ message: 'bad target' }),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
makeClient('sk_test').checkVerification('+1', 'x'),
|
||||
).rejects.toThrow(/Prelude .* failed: 400/);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user